feat(cloud-agent): 云端 Agent - 完善运行时与资源持久化

This commit is contained in:
ddcat666
2026-09-26 17:05:36 +08:00
parent 48e6a705ef
commit 993580a7bd
40 changed files with 1739 additions and 440 deletions

View File

@@ -1,19 +1,24 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"reflect"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"infinite-canvas/backend/internal/database"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
)
type tableMigration struct {
@@ -50,17 +55,17 @@ func main() {
// PostgreSQL 的 DDL 参与事务;任一表复制或核对失败都会回滚整个新库结构。
if err := target.Transaction(func(tx *gorm.DB) error {
tableCount, err := publicTableCount(tx)
tableNames, err := publicTableNames(tx)
if err != nil {
return err
}
copyRows := tableCount == 0
copyRows := len(tableNames) == 0
if copyRows {
if err := database.MigrateSchema(tx); err != nil {
return fmt.Errorf("创建目标表结构:%w", err)
}
} else if tableCount != int64(len(migrations())) {
return fmt.Errorf("PostgreSQL public schema 已有 %d 张表,拒绝覆盖或补写", tableCount)
} else if err := validateTargetTableNames(tableNames); err != nil {
return err
}
total := 0
@@ -77,6 +82,9 @@ func main() {
} else {
log.Printf("目标库已有完整迁移结果,未重复写入:%d 张表,%d 行", len(migrations()), total)
}
if err := database.ReconcilePrefixedIDSequences(tx); err != nil {
return fmt.Errorf("校准可读 ID 序列:%w", err)
}
return nil
}); err != nil {
log.Fatal(err)
@@ -94,10 +102,52 @@ func verifySQLite(db *gorm.DB) error {
return nil
}
func publicTableCount(db *gorm.DB) (int64, error) {
var count int64
err := db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public'").Scan(&count).Error
return count, err
func publicTableNames(db *gorm.DB) ([]string, error) {
var names []string
err := db.Raw(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name
`).Scan(&names).Error
return names, err
}
// validateTargetTableNames compares the existing PostgreSQL schema against the
// same business-table list used by Models() and migrations().
// schema_migrations is created by database.MigrateSchema and is intentionally
// not part of the row-copy list, so it is allowed as the only system table.
func validateTargetTableNames(actual []string) error {
expected := make(map[string]struct{}, len(migrations()))
for _, migration := range migrations() {
expected[migration.name] = struct{}{}
}
actualSet := make(map[string]struct{}, len(actual))
for _, name := range actual {
actualSet[name] = struct{}{}
}
missing := make([]string, 0)
for name := range expected {
if _, ok := actualSet[name]; !ok {
missing = append(missing, name)
}
}
extra := make([]string, 0)
for name := range actualSet {
if name == "schema_migrations" {
continue
}
if _, ok := expected[name]; !ok {
extra = append(extra, name)
}
}
sort.Strings(missing)
sort.Strings(extra)
if len(missing) > 0 || len(extra) > 0 {
return fmt.Errorf("PostgreSQL public schema 与当前模型清单不一致,缺少表:%s,额外表:%s;拒绝覆盖或补写", strings.Join(missing, ", "), strings.Join(extra, ", "))
}
return nil
}
func migrateTable[T any](name string) tableMigration {
@@ -112,6 +162,9 @@ func migrateTable[T any](name string) tableMigration {
if err := source.Order(primaryKey).Find(&sourceRows).Error; err != nil {
return 0, err
}
if err := validateExplicitStringLengths(source, name, sourceRows); err != nil {
return 0, err
}
if copyRows && len(sourceRows) > 0 {
if err := target.CreateInBatches(&sourceRows, 100).Error; err != nil {
return 0, err
@@ -130,6 +183,102 @@ func migrateTable[T any](name string) tableMigration {
}
}
// validateExplicitStringLengths catches values that SQLite accepts but
// PostgreSQL would reject for a GORM size:N column. SQLite does not enforce
// VARCHAR(N), so this check must happen before the first target insert.
func validateExplicitStringLengths[T any](db *gorm.DB, tableName string, rows []T) error {
statement := &gorm.Statement{DB: db}
if err := statement.Parse(new(T)); err != nil {
return err
}
if statement.Schema == nil {
return fmt.Errorf("表 %s 缺少 GORM schema", tableName)
}
for rowIndex := range rows {
rowValue := reflect.ValueOf(rows[rowIndex])
key := primaryKeyValue(statement.Schema.PrimaryFields, rowValue)
for _, field := range statement.Schema.Fields {
limit, ok := explicitStringSize(field)
if !ok || field.Serializer != nil || !field.Creatable || !field.Readable {
continue
}
value, zero := field.ValueOf(context.Background(), rowValue)
if zero {
continue
}
text, ok := stringValue(value)
if !ok {
continue
}
actual := utf8.RuneCountInString(text)
if actual <= limit {
continue
}
return fmt.Errorf("表 %s 第 %d 行(主键 %s)列 %s 长度 %d,超过 size:%d", tableName, rowIndex+1, key, field.DBName, actual, limit)
}
}
return nil
}
func explicitStringSize(field *schema.Field) (int, bool) {
// This adapter keeps the validation logic independent from GORM's inferred
// default size (often 255). Only an explicit size:N tag is a PostgreSQL
// contract we can prove from the model.
sizeText, ok := field.TagSettings["SIZE"]
if !ok || strings.TrimSpace(sizeText) == "" {
return 0, false
}
if strings.EqualFold(strings.TrimSpace(field.TagSettings["TYPE"]), "text") {
return 0, false
}
if field.IndirectFieldType.Kind() != reflect.String {
return 0, false
}
limit, err := strconv.Atoi(sizeText)
if err != nil || limit < 0 {
return 0, false
}
return limit, true
}
func stringValue(value any) (string, bool) {
if value == nil {
return "", false
}
rv := reflect.ValueOf(value)
for rv.Kind() == reflect.Interface || rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return "", false
}
rv = rv.Elem()
}
if rv.Kind() != reflect.String {
return "", false
}
return rv.String(), true
}
func primaryKeyValue(fields []*schema.Field, rowValue reflect.Value) string {
if len(fields) == 0 {
return "<无主键>"
}
parts := make([]string, 0, len(fields))
for _, field := range fields {
value, zero := field.ValueOf(context.Background(), rowValue)
if zero || value == nil {
parts = append(parts, field.DBName+"=<空>")
continue
}
if text, ok := stringValue(value); ok {
parts = append(parts, field.DBName+"="+text)
continue
}
parts = append(parts, field.DBName+"="+fmt.Sprint(value))
}
return strings.Join(parts, ",")
}
func primaryKeyColumn[T any](db *gorm.DB) (string, error) {
statement := &gorm.Statement{DB: db}
if err := statement.Parse(new(T)); err != nil {

View File

@@ -331,7 +331,7 @@ func cloudAgentApprovalCallHash(call cloudAgentCall) string {
// server revalidates the prepared dependency hash below, which deliberately
// ignores layout-only edits such as moving a node.
arguments := call.Function.Arguments
if call.Function.Name == "generate_media" {
if call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split" {
var object map[string]any
if err := json.Unmarshal([]byte(arguments), &object); err == nil {
delete(object, "snapshotHash")

View File

@@ -24,7 +24,7 @@ func TestCloudAgentMediaReadHashSurvivesMoveBeforeDraft(t *testing.T) {
if err := db.Model(canvas).Update("payload_json", string(raw)).Error; err != nil {
t.Fatal(err)
}
run, _ := agentMediaRun(t, s, args, "auto")
run, _ := agentMediaRun(t, s, args, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
@@ -39,7 +39,7 @@ func TestCloudAgentMediaApprovalAllowsMovesButRejectsContentChanges(t *testing.T
for _, change := range []string{"move", "presentation", "prompt", "resource", "connection", "locked", "task", "unknown_metadata"} {
t.Run(change, func(t *testing.T) {
s, db, a := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, a, "auto")
run, _ := agentMediaRun(t, s, a, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
@@ -112,7 +112,7 @@ func TestCloudAgentMediaApprovalAllowsMovesButRejectsContentChanges(t *testing.T
func TestCloudAgentMediaApprovalReusesPreparedInputsAfterModelRetry(t *testing.T) {
s, db, a := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, a, "auto")
run, _ := agentMediaRun(t, s, a, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
@@ -170,7 +170,7 @@ func TestCloudAgentImageApprovalEditsAreValidatedAndIdempotent(t *testing.T) {
a.Mode, a.ChannelModelKey, a.Duration, a.VideoGenerateAudio = "image", "grok-image", 0, nil
a.Size, a.Quality, a.NodeID = "1:1", "2k", "image-shot-1"
a.ReferenceNodeIDs = []string{"cat"}
run, _ := agentMediaRun(t, s, a, "auto")
run, _ := agentMediaRun(t, s, a, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}

View File

@@ -31,7 +31,7 @@ func TestCloudAgentCanvasPatchesPersistDraftSubmissionAndAllTerminalStates(t *te
for _, status := range []model.TaskStatus{model.TaskStatusSucceeded, model.TaskStatusFailed, model.TaskStatusCancelled} {
t.Run(string(status), func(t *testing.T) {
s, db, args := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, args, "auto")
run, _ := agentMediaRun(t, s, args, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
@@ -128,7 +128,7 @@ func TestCloudAgentCanvasPatchesPersistDraftSubmissionAndAllTerminalStates(t *te
func TestCloudAgentMediaImageSourceHasActionableCorrection(t *testing.T) {
s, _, args := agentMediaFixture(t)
run, state := agentMediaRun(t, s, args, "auto")
run, state := agentMediaRun(t, s, args, "request_approval")
args.SourceNodeID = "cat"
if _, _, err := s.prepareCloudAgentMedia(run, &state, agentMediaCall(args)); err == nil || !strings.Contains(err.Error(), "referenceNodeIds") {
t.Fatalf("image source must point to the correct field: %v", err)
@@ -226,7 +226,7 @@ func TestCloudAgentCanvasOperationTraceSharesToolCallID(t *testing.T) {
func TestCloudAgentMissingCanvasStillCheckpointsMediaFailure(t *testing.T) {
s, db, args := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, args, "auto")
run, _ := agentMediaRun(t, s, args, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}

View File

@@ -148,7 +148,7 @@ func TestCloudAgentMediaAdmissionReportsTaskBinding(t *testing.T) {
func TestCloudAgentCancellationRecordsSourceOnce(t *testing.T) {
s, _, args := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, args, "auto")
run, _ := agentMediaRun(t, s, args, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}

View File

@@ -14,10 +14,18 @@ func cloudAgentGenerationSpec(a cloudAgentMediaArgs, refs map[string]any, resolv
}
options := contract.Options{}
if a.Mode == "image" || a.Mode == "video" {
options.Size = &a.Size
// A blank option means "use the selected model's declared default". Do
// not turn absence into an explicit empty value: task admission is where
// the model capability contract resolves defaults and validates support.
if strings.TrimSpace(a.Size) != "" {
options.Size = &a.Size
}
}
if a.Mode == "video" {
options.DurationSeconds, options.GenerateAudio = &a.Duration, a.VideoGenerateAudio
if a.Duration > 0 {
options.DurationSeconds = &a.Duration
}
options.GenerateAudio = a.VideoGenerateAudio
if a.Quality != "" {
options.Resolution = &a.Quality
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
@@ -19,8 +20,12 @@ func (s *Service) cloudAgentModelList(intent *ModelRequestIntent) (any, error) {
if err != nil {
return nil, err
}
logicalModels, err := s.PublicLogicalModels(intent)
if err != nil {
return nil, err
}
items := []map[string]any{}
for _, m := range catalog.Models {
for _, m := range logicalModels {
if m.Available && cloudAgentGenerationModeSupported(normalizeCapability(m.Capability)) {
items = append(items, map[string]any{"name": m.Name, "capability": m.Capability, "selection": map[string]any{"logicalModelId": m.ID}, "priceLabel": m.PriceLabel, "priceTiers": m.PriceTiers, "options": m.CapabilitySpec, "profiles": m.CapabilityProfiles, "defaults": m.DefaultOptions})
}
@@ -28,7 +33,7 @@ func (s *Service) cloudAgentModelList(intent *ModelRequestIntent) (any, error) {
for _, channel := range catalog.Channels {
for _, m := range channel.Models {
if m.Available && cloudAgentGenerationModeSupported(normalizeCapability(m.Capability)) {
items = append(items, map[string]any{"name": m.DisplayName, "capability": m.Capability, "selection": map[string]any{"channelId": channel.ID, "channelModelKey": m.ModelKey}, "priceLabel": m.PriceLabel, "priceTiers": m.PriceTiers, "options": m.CapabilityConfig})
items = append(items, map[string]any{"name": m.DisplayName, "capability": m.Capability, "selection": map[string]any{"channelId": channel.ID, "channelModelKey": m.ModelKey}, "priceLabel": m.PriceLabel, "priceTiers": m.PriceTiers, "options": m.CapabilityConfig, "defaults": m.DefaultOptions})
}
}
}
@@ -109,6 +114,9 @@ type cloudAgentMediaPlan struct {
Args cloudAgentMediaArgs
CallID string
TransientReferences map[string]cloudAgentTransientReference
// Prepared is populated by the auto path after its dry admission. Approval
// mode keeps the same admission in state.Approval.Prepared.
Prepared *cloudAgentPreparedMedia `json:"-"`
}
// A resumed draft's incoming edges must describe the new approved inputs,
@@ -161,7 +169,11 @@ func (s *Service) cloudAgentMediaModelName(a cloudAgentMediaArgs) (string, error
if err != nil {
return "", err
}
for _, m := range catalog.Models {
logicalModels, err := s.PublicLogicalModels(nil)
if err != nil {
return "", err
}
for _, m := range logicalModels {
if a.LogicalModelID != "" && m.ID == a.LogicalModelID && m.Available && normalizeCapability(m.Capability) == normalizeCapability(a.Mode) {
return m.Name, nil
}
@@ -422,17 +434,13 @@ func validateCloudAgentMediaArgs(a cloudAgentMediaArgs, state *cloudAgentRuntime
if utf8.RuneCountInString(a.Prompt) > 16000 {
return BadAuthRequest(fmt.Sprintf("提示词共%d字符,超过16000字符上限;请先告知用户,不要擅自删改关键内容", utf8.RuneCountInString(a.Prompt)))
}
if (mode == "image" || mode == "video") && strings.TrimSpace(a.Size) == "" {
return BadAuthRequest("请填写模型支持的具体画幅;用户授权默认时沿用参考图比例或目录默认画幅,无需重复询问")
}
if a.Duration < 0 {
return BadAuthRequest("生成时长不能为负数")
}
switch mode {
case "video":
if a.Duration == 0 {
return BadAuthRequest("视频生成必须明确 durationSeconds")
}
// 0 means omitted. The selected model's capability defaults are resolved
// during task admission; an explicit unsupported value still fails there.
case "image", "audio":
if a.Duration != 0 {
return BadAuthRequest("只有视频生成允许设置 durationSeconds")
@@ -540,14 +548,36 @@ func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state *
if err := decodeCloudAgentJSONObject(call.Function.Arguments, &a); err != nil {
return CreateTaskRequest{}, nil, cloudAgentJSONArgumentError(err)
}
if err := validateCloudAgentModelSelection(call.Function.Arguments, a); err != nil {
a.Mode = strings.ToLower(strings.TrimSpace(a.Mode))
defaultedModel, err := s.applyCloudAgentProjectDefaultModel(run, state, call.Function.Arguments, &a)
if err != nil {
return CreateTaskRequest{}, nil, err
}
selectionArguments := call.Function.Arguments
if defaultedModel {
selectionFields := map[string]string{}
if a.LogicalModelID != "" {
selectionFields["logicalModelId"] = a.LogicalModelID
} else {
selectionFields["channelId"], selectionFields["channelModelKey"] = a.ChannelID, a.ChannelModelKey
}
encoded, encodeErr := json.Marshal(selectionFields)
if encodeErr != nil {
return CreateTaskRequest{}, nil, encodeErr
}
selectionArguments = string(encoded)
}
if err := validateCloudAgentModelSelection(selectionArguments, a); err != nil {
return CreateTaskRequest{}, nil, err
}
a.Mode = strings.ToLower(strings.TrimSpace(a.Mode))
a.DraftRunID = run.ID
if state.Approval != nil && state.Approval.Prepared != nil &&
state.Approval.CallHash == cloudAgentApprovalCallHash(call) {
a.Prepared = state.Approval.Prepared
} else if state.AutoPreparedMedia != nil &&
state.AutoPreparedCallHash == cloudAgentApprovalCallHash(call) &&
time.Now().Before(state.AutoPreparedMedia.Quote.ExpiresAt) {
a.Prepared = state.AutoPreparedMedia
}
if err := s.fillCloudAgentMediaSnapshotHash(run.UserID, state.Request.CanvasID, &a); err != nil {
return CreateTaskRequest{}, nil, err
@@ -593,7 +623,52 @@ func (s *Service) prepareCloudAgentMedia(run *model.CloudAgentExecution, state *
for id, ref := range state.TransientReferences {
transientSnapshot[id] = ref
}
return CreateTaskRequest{ProjectID: state.Request.CanvasID, Type: "canvas_" + a.Mode, Operation: operation, Prompt: a.Prompt, LogicalModelID: a.LogicalModelID, Model: a.ChannelModelKey, Input: input}, &cloudAgentMediaPlan{Args: a, CallID: call.ID, TransientReferences: transientSnapshot}, nil
return CreateTaskRequest{ProjectID: state.Request.CanvasID, Type: "canvas_" + a.Mode, Operation: operation, Prompt: a.Prompt, LogicalModelID: a.LogicalModelID, Model: a.ChannelModelKey, Input: input}, &cloudAgentMediaPlan{Args: a, CallID: call.ID, TransientReferences: transientSnapshot, Prepared: a.Prepared}, nil
}
// applyCloudAgentResolvedMediaDefaults makes the result of the dry admission
// explicit in both the plan and the request that will be submitted. The
// regular task admission already owns default resolution; this helper only
// mirrors that resolved contract back to the Agent state so auto execution does
// not need to fail once for size/duration and then ask the model to repair it.
func applyCloudAgentResolvedMediaDefaults(req *CreateTaskRequest, plan *cloudAgentMediaPlan, task *model.Task) error {
if req == nil || plan == nil || task == nil {
return BadAuthRequest("媒体生成准入结果无效,未提交生成任务")
}
var input map[string]any
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
return err
}
config, ok := input["config"].(map[string]any)
if !ok {
return BadAuthRequest("媒体生成未解析出模型配置,未提交生成任务")
}
plan.Args.Size = stringValue(config["size"])
if plan.Args.Mode == "image" {
plan.Args.Quality = stringValue(config["quality"])
}
if plan.Args.Mode == "video" {
plan.Args.Quality = stringValue(config["vquality"])
if raw := stringValue(config["videoSeconds"]); raw != "" {
seconds, err := strconv.Atoi(raw)
if err != nil || seconds < 0 {
return BadAuthRequest("模型目录返回的默认视频时长无效,未提交生成任务")
}
plan.Args.Duration = seconds
}
if raw := stringValue(config["videoGenerateAudio"]); raw != "" {
audio, err := strconv.ParseBool(raw)
if err != nil {
return BadAuthRequest("模型目录返回的默认音频参数无效,未提交生成任务")
}
plan.Args.VideoGenerateAudio = &audio
}
}
plan.Args.Prepared = nil
req.Input = input
req.Prompt = stringValue(input["prompt"])
return nil
}
func saveCloudAgentDocument(repo *repository.Repository, canvas *model.CanvasProject, doc map[string]any, policy RuntimePolicySetting) error {

View File

@@ -83,8 +83,19 @@ func agentMediaRun(t *testing.T, s *Service, a cloudAgentMediaArgs, permission s
func approveAgentMediaDraft(t *testing.T, s *Service, runID string) {
t.Helper()
run, err := s.CloudAgentRun("user", runID)
if err != nil || run.Approval == nil || run.Status != "waiting_approval" {
t.Fatalf("media must wait for approval even in auto mode: %v", err)
if err != nil {
t.Fatal(err)
}
if run.Status != "waiting_approval" || run.Approval == nil {
stored, loadErr := s.repo.CloudAgent("user", runID)
state, decodeErr := cloudAgentDecode(stored)
if loadErr != nil {
decodeErr = loadErr
}
if decodeErr != nil || state.MediaTaskID == "" {
t.Fatalf("media generation was neither approved nor auto-submitted: status=%s task=%s err=%v", run.Status, state.MediaTaskID, decodeErr)
}
return
}
if err := s.DecideCloudAgentApproval("user", runID, run.Approval.ID, "approve", ""); err != nil {
t.Fatal(err)
@@ -196,7 +207,6 @@ func TestCloudAgentMediaReferenceAndSnapshotGuards(t *testing.T) {
{"existing node", func(v *cloudAgentMediaArgs) { v.NodeID = "cat" }},
{"mixed selection", func(v *cloudAgentMediaArgs) { v.LogicalModelID = "made-up" }},
{"duration budget", func(v *cloudAgentMediaArgs) { v.Duration = 25 }},
{"missing aspect ratio", func(v *cloudAgentMediaArgs) { v.Size = "" }},
} {
t.Run(test.name, func(t *testing.T) {
bad := a
@@ -234,7 +244,7 @@ func TestCloudAgentImageCreatesReferencedNode(t *testing.T) {
a.Mode, a.ChannelModelKey, a.Duration, a.VideoGenerateAudio = "image", "grok-image", 0, nil
a.Size, a.Quality, a.NodeID = "1:1", "2k", "image-shot-1"
a.ReferenceNodeIDs = []string{"cat"}
run, _ := agentMediaRun(t, s, a, "auto")
run, _ := agentMediaRun(t, s, a, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
@@ -399,7 +409,7 @@ func TestCloudAgentMediaFillsMissingSnapshotHash(t *testing.T) {
func TestCloudAgentMediaFailedTaskUpdatesNode(t *testing.T) {
s, db, a := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, a, "auto")
run, _ := agentMediaRun(t, s, a, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
@@ -545,7 +555,7 @@ func TestCloudAgentMediaDraftReuseLifecycle(t *testing.T) {
func TestCloudAgentMediaPreviousDraftRequiresNewApproval(t *testing.T) {
s, db, a := agentMediaFixture(t)
run, state := agentMediaRun(t, s, a, "auto")
run, state := agentMediaRun(t, s, a, "request_approval")
if err := s.advanceCloudAgentTool(run, &state); err != nil {
t.Fatal(err)
}
@@ -564,7 +574,7 @@ func TestCloudAgentMediaPreviousDraftRequiresNewApproval(t *testing.T) {
}
a.SnapshotHash = cloudAgentCanvasHash(doc)
a.ReferenceNodeIDs = []string{"hero"}
next, nextState := agentMediaRun(t, s, a, "auto", "resume-draft-next-turn")
next, nextState := agentMediaRun(t, s, a, "request_approval", "resume-draft-next-turn")
if next.ID == run.ID {
t.Fatal("expected a distinct run")
}
@@ -605,74 +615,130 @@ func TestCloudAgentMediaPreviousDraftRequiresNewApproval(t *testing.T) {
}
}
func TestCloudAgentAutoMediaDraftRequiresExplicitApproval(t *testing.T) {
for _, decision := range []string{"approve", "reject", "cancel"} {
t.Run(decision, func(t *testing.T) {
s, db, a := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, a, "auto")
var ordersBefore int64
db.Model(&model.BillingOrder{}).Count(&ordersBefore)
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
run, _ = s.repo.CloudAgent("user", run.ID)
state, _ := cloudAgentDecode(run)
if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ModelName != "视频测试" || state.MediaTaskID != "" {
t.Fatal("auto mode bypassed approval or omitted selected model")
}
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ := creationDocument(canvas.PayloadJSON)
nodes, _ := creationObjects(doc["nodes"])
meta, _ := nodes[a.NodeID]["metadata"].(map[string]any)
if meta["status"] != "idle" || meta["taskId"] != nil || meta["size"] != "9:16" || meta["agentDraftRunId"] != run.ID || len(creationMaps(doc["connections"])) != 3 {
t.Fatalf("missing uncharged draft with references: %+v", meta)
}
// Re-entering the tool while awaiting a decision must remain a no-op.
if err := s.advanceCloudAgentTool(run, &state); err != nil {
t.Fatal(err)
}
var count, orders int64
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
db.Model(&model.BillingOrder{}).Count(&orders)
if count != 0 || orders != ordersBefore {
t.Fatal("draft created a task or charge")
}
// Another run may not take over this draft, even with the latest snapshot.
foreign := a
foreign.DraftRunID, foreign.SnapshotHash = "another-run", cloudAgentCanvasHash(doc)
if _, _, _, err := cloudAgentMediaDocument(s.repo, "user", "agent-canvas", foreign); err == nil {
t.Fatal("foreign draft overwrite accepted")
}
if decision == "cancel" {
if err := s.CancelCloudAgent(context.Background(), "user", run.ID); err != nil {
t.Fatal(err)
}
} else {
// A local focus/zoom autosave cannot change the approved content.
doc["viewport"], doc["updatedAt"] = map[string]any{"x": -900, "y": -500, "k": 0.8}, "later"
raw, _ := json.Marshal(doc)
if err := db.Model(&model.CanvasProject{}).Where("id = ?", "agent-canvas").Update("payload_json", string(raw)).Error; err != nil {
t.Fatal(err)
}
for range 2 {
if err := s.DecideCloudAgentApproval("user", run.ID, state.Approval.ID, decision, ""); err != nil {
t.Fatal(err)
}
}
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
}
db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&count)
db.Model(&model.BillingOrder{}).Count(&orders)
if decision == "approve" {
if count != 1 {
t.Fatal("explicit approval did not create exactly one task")
}
} else if count != 0 || orders != ordersBefore {
t.Fatal("reject/cancel submitted generation")
}
})
func TestCloudAgentAutoMediaSubmitsWithoutApproval(t *testing.T) {
s, db, a := agentMediaFixture(t)
// Omitted size and duration should be filled by the selected model's catalog
// defaults during admission instead of producing a repair turn.
a.Size = ""
a.Duration = 0
run, _ := agentMediaRun(t, s, a, "auto")
var ordersBefore int64
if err := db.Model(&model.BillingOrder{}).Count(&ordersBefore).Error; err != nil {
t.Fatal(err)
}
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
run, _ = s.repo.CloudAgent("user", run.ID)
state, err := cloudAgentDecode(run)
if err != nil {
t.Fatal(err)
}
if run.Status == "waiting_approval" || state.Approval != nil {
t.Fatalf("auto mode unexpectedly requested approval: status=%s approval=%+v", run.Status, state.Approval)
}
if state.MediaTaskID == "" {
t.Fatalf("auto mode did not submit a media task: %+v", state.Events)
}
for _, event := range state.Events {
if event.Type == "approval_requested" {
t.Fatal("auto mode emitted an approval event")
}
}
task, err := s.repo.TaskForUser("user", state.MediaTaskID)
if err != nil {
t.Fatal(err)
}
var input canvasGenerationInput
if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil {
t.Fatal(err)
}
if input.Config.Size == "" || input.Config.VideoSeconds == "" {
t.Fatalf("model defaults were not resolved: %+v", input.Config)
}
canvas, _ := s.repo.CanvasProjectForUser("user", "agent-canvas")
doc, _ := creationDocument(canvas.PayloadJSON)
nodes, _ := creationObjects(doc["nodes"])
meta, _ := nodes[a.NodeID]["metadata"].(map[string]any)
if meta["status"] != "loading" || meta["taskId"] != task.ID || meta["agentDraftRunId"] != nil {
t.Fatalf("auto submission did not finalize the draft: %+v", meta)
}
var tasks, orders int64
if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&tasks).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.BillingOrder{}).Count(&orders).Error; err != nil {
t.Fatal(err)
}
if tasks != 1 || orders != ordersBefore+1 {
t.Fatalf("auto submission created unexpected records: tasks=%d orders=%d before=%d", tasks, orders, ordersBefore)
}
// Re-entering a submitted call is idempotent and must not reserve/submit a
// second generation.
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
if err := db.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&tasks).Error; err != nil {
t.Fatal(err)
}
if tasks != 1 {
t.Fatalf("re-entering auto media duplicated task: %d", tasks)
}
// Admission failures are still non-billed tool failures and do not create an
// approval or a draft task.
invalidService, invalidDB, invalidArgs := agentMediaFixture(t)
invalidArgs.ChannelModelKey = "removed-model"
invalidRun, _ := agentMediaRun(t, invalidService, invalidArgs, "auto")
var invalidOrdersBefore int64
if err := invalidDB.Model(&model.BillingOrder{}).Count(&invalidOrdersBefore).Error; err != nil {
t.Fatal(err)
}
if err := invalidService.advanceCloudAgentByID("user", invalidRun.ID); err != nil {
t.Fatal(err)
}
invalidRun, _ = invalidService.repo.CloudAgent("user", invalidRun.ID)
invalidState, err := cloudAgentDecode(invalidRun)
if err != nil {
t.Fatal(err)
}
var invalidTasks, invalidOrders int64
if err := invalidDB.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&invalidTasks).Error; err != nil {
t.Fatal(err)
}
if err := invalidDB.Model(&model.BillingOrder{}).Count(&invalidOrders).Error; err != nil {
t.Fatal(err)
}
if invalidTasks != 0 || invalidOrders != invalidOrdersBefore || invalidState.Approval != nil {
t.Fatalf("invalid auto admission created side effects: tasks=%d orders=%d before=%d approval=%+v", invalidTasks, invalidOrders, invalidOrdersBefore, invalidState.Approval)
}
approvalService, approvalDB, approvalArgs := agentMediaFixture(t)
approvalRun, _ := agentMediaRun(t, approvalService, approvalArgs, "request_approval")
var approvalOrdersBefore int64
if err := approvalDB.Model(&model.BillingOrder{}).Count(&approvalOrdersBefore).Error; err != nil {
t.Fatal(err)
}
if err := approvalService.advanceCloudAgentByID("user", approvalRun.ID); err != nil {
t.Fatal(err)
}
approvalRun, _ = approvalService.repo.CloudAgent("user", approvalRun.ID)
approvalState, err := cloudAgentDecode(approvalRun)
if err != nil {
t.Fatal(err)
}
if approvalRun.Status != "waiting_approval" || approvalState.Approval == nil {
t.Fatalf("request_approval stopped bypassing approval: status=%s approval=%+v", approvalRun.Status, approvalState.Approval)
}
var approvalTasks, approvalOrders int64
if err := approvalDB.Model(&model.Task{}).Where("type = ?", "canvas_video").Count(&approvalTasks).Error; err != nil {
t.Fatal(err)
}
if err := approvalDB.Model(&model.BillingOrder{}).Count(&approvalOrders).Error; err != nil {
t.Fatal(err)
}
if approvalTasks != 0 || approvalOrders != approvalOrdersBefore {
t.Fatalf("request_approval submitted before decision: tasks=%d orders=%d before=%d", approvalTasks, approvalOrders, approvalOrdersBefore)
}
}
@@ -692,7 +758,7 @@ func TestCloudAgentMediaPromptCountsUnicodeCharacters(t *testing.T) {
func TestCloudAgentMediaCancellationUpdatesNode(t *testing.T) {
s, _, a := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, a, "auto")
run, _ := agentMediaRun(t, s, a, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}

View File

@@ -13,7 +13,7 @@ func TestCloudAgentMediaCleanupUsesPersistedTaskTarget(t *testing.T) {
for _, missingTarget := range []bool{false, true} {
t.Run(map[bool]string{false: "known target", true: "unknown target"}[missingTarget], func(t *testing.T) {
s, db, args := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, args, "auto")
run, _ := agentMediaRun(t, s, args, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}
@@ -81,7 +81,7 @@ func TestCloudAgentMediaCompletionKeepsGenerationAndWritebackFailures(t *testing
} {
t.Run(test.name, func(t *testing.T) {
s, db, args := agentMediaFixture(t)
run, _ := agentMediaRun(t, s, args, "auto")
run, _ := agentMediaRun(t, s, args, "request_approval")
if err := s.advanceCloudAgentByID("user", run.ID); err != nil {
t.Fatal(err)
}

View File

@@ -2,12 +2,104 @@ package app
import (
"encoding/json"
"fmt"
"strings"
"infinite-canvas/backend/internal/model"
)
// Keep cross-field validation server-side rather than adding provider-specific
// root schema combinators. The same contract is advertised by both media tools.
const cloudAgentModelSelectionDescription = "模型选择必填:将 model_list 返回的一种 selection 展开到顶层参数,必须提供非空 logicalModelId,或同时提供非空 channelId 和 channelModelKey,二者互斥。未使用的选择字段省略或传空字符串,不得传 null 或仅含空白的字符串。缺失、混用或不完整均在提交前拒绝,不会自动选择或切换模型。"
const cloudAgentModelSelectionDescription = "模型选择:复制 model_list 的 selection 到顶层。完全省略模型字段时,仅使用当前项目对应能力的可用默认模型。显式选择必须提供 logicalModelId,或同时提供 channelId 与 channelModelKey,二者互斥。未使用字段省略或传空字符串,不得传 null/空白;无可用默认、混用或不完整均拒绝,不会随机选模。"
// applyCloudAgentProjectDefaultModel only fills a selection when the Agent did
// not send any model-selection field at all. An explicitly empty, partial, or
// null selection remains invalid so a malformed tool call cannot silently turn
// into a different model.
func (s *Service) applyCloudAgentProjectDefaultModel(run *model.CloudAgentExecution, state *cloudAgentRuntime, raw string, a *cloudAgentMediaArgs) (bool, error) {
if s == nil || s.repo == nil || run == nil || state == nil || a == nil || cloudAgentModelSelectionProvided(raw) {
return false, nil
}
a.Mode = strings.ToLower(strings.TrimSpace(a.Mode))
if a.Mode != "image" && a.Mode != "video" || strings.TrimSpace(state.Request.CanvasID) == "" {
return false, nil
}
canvas, err := s.repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID)
if err != nil {
return false, err
}
if strings.TrimSpace(canvas.ProjectID) == "" {
return false, nil
}
project, err := s.repo.ProjectForUser(run.UserID, canvas.ProjectID)
if err != nil {
return false, err
}
defaultModel := strings.TrimSpace(project.DefaultImageModel)
if a.Mode == "video" {
defaultModel = strings.TrimSpace(project.DefaultVideoModel)
}
if defaultModel == "" {
return false, nil
}
if channelID, channelModelKey, ok := splitCloudAgentChannelModel(defaultModel); ok {
catalog, catalogErr := s.ModelCatalog(nil)
if catalogErr != nil {
return false, catalogErr
}
for _, channel := range catalog.Channels {
if channel.ID != channelID {
continue
}
for _, channelModel := range channel.Models {
if channelModel.ModelKey == channelModelKey && channelModel.Available && normalizeCapability(channelModel.Capability) == a.Mode {
a.ChannelID, a.ChannelModelKey = channelID, channelModelKey
return true, nil
}
}
}
return false, fmt.Errorf("项目默认%s模型不可用或能力不匹配,请在项目设置中重新选择模型", cloudAgentModeLabel(a.Mode))
}
logicalModels, err := s.PublicLogicalModels(nil)
if err != nil {
return false, err
}
for _, logicalModel := range logicalModels {
if logicalModel.ID == defaultModel && logicalModel.Available && normalizeCapability(logicalModel.Capability) == a.Mode {
a.LogicalModelID = defaultModel
return true, nil
}
}
return false, fmt.Errorf("项目默认%s模型不可用或能力不匹配,请在项目设置中重新选择模型", cloudAgentModeLabel(a.Mode))
}
func cloudAgentModelSelectionProvided(raw string) bool {
var fields map[string]json.RawMessage
if err := json.Unmarshal([]byte(raw), &fields); err != nil || fields == nil {
return true
}
for _, field := range []string{"logicalModelId", "channelId", "channelModelKey"} {
if _, ok := fields[field]; ok {
return true
}
}
return false
}
func splitCloudAgentChannelModel(value string) (string, string, bool) {
channelID, channelModelKey, ok := strings.Cut(strings.TrimSpace(value), "::")
channelID, channelModelKey = strings.TrimSpace(channelID), strings.TrimSpace(channelModelKey)
return channelID, channelModelKey, ok && channelID != "" && channelModelKey != ""
}
func cloudAgentModeLabel(mode string) string {
if mode == "video" {
return "视频"
}
return "图片"
}
func validateCloudAgentModelSelection(raw string, a cloudAgentMediaArgs) error {
// Go's JSON decoder accepts null for string fields; the tool contract does
@@ -33,7 +125,7 @@ func validateCloudAgentModelSelection(raw string, a cloudAgentMediaArgs) error {
case a.LogicalModelID != "":
return nil
case a.ChannelID == "" && a.ChannelModelKey == "":
return cloudAgentFieldError("logicalModelId", "required", "缺少模型选择:必须复制 model_list 的 logicalModelId 或完整的 channelId/channelModelKey,不会自动选择模型")
return cloudAgentFieldError("logicalModelId", "required", "缺少模型选择:请复制 model_list 的 logicalModelId 或完整的 channelId/channelModelKey;若项目未配置当前能力的可用默认模型,不能自动猜测")
case a.ChannelID == "":
return cloudAgentFieldError("channelId", "required", "系统渠道模型选择不完整:缺少 channelId,请复制 model_list 的完整 selection")
case a.ChannelModelKey == "":

View File

@@ -5,10 +5,23 @@ import (
"errors"
"strings"
"testing"
"time"
"gorm.io/gorm"
"infinite-canvas/backend/internal/model"
)
func cloudAgentProjectForDefaultModel(t *testing.T, db *gorm.DB, modelRef string) {
t.Helper()
project := &model.Project{ID: "agent-default-project", UserID: "user", Name: "Agent 默认模型项目", Type: "short-drama", Status: model.ProjectStatusActive, DefaultImageModel: modelRef, DefaultVideoModel: modelRef}
if err := db.Create(project).Error; err != nil {
t.Fatal(err)
}
if err := db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", "agent-canvas", "user").Update("project_id", project.ID).Error; err != nil {
t.Fatal(err)
}
}
func TestCloudAgentModelSelectionRejectsBeforeCanvasRead(t *testing.T) {
for _, tc := range []struct{ name, args, field, issue string }{
{"missing", `{}`, "logicalModelId", "required"},
@@ -62,6 +75,54 @@ func TestCloudAgentModelSelectionAcceptsOnlyCompleteSelections(t *testing.T) {
}
}
func TestCloudAgentProjectDefaultChannelModelFillsOnlyOmittedSelection(t *testing.T) {
s, db, _ := agentMediaFixture(t)
cloudAgentProjectForDefaultModel(t, db, "channel::seedance-test")
run := &model.CloudAgentExecution{ID: "run-default-channel", UserID: "user"}
state := &cloudAgentRuntime{Request: CloudAgentRequest{CanvasID: "agent-canvas"}}
args := cloudAgentMediaArgs{Mode: "video"}
used, err := s.applyCloudAgentProjectDefaultModel(run, state, `{"mode":"video"}`, &args)
if err != nil || !used || args.ChannelID != "channel" || args.ChannelModelKey != "seedance-test" || args.LogicalModelID != "" {
t.Fatalf("default channel selection = used:%v args:%+v err:%v", used, args, err)
}
args = cloudAgentMediaArgs{Mode: "video"}
used, err = s.applyCloudAgentProjectDefaultModel(run, state, `{"mode":"video","channelId":"","channelModelKey":""}`, &args)
if err != nil || used {
t.Fatalf("explicit empty selection was silently defaulted: used:%v args:%+v err:%v", used, args, err)
}
if err := validateCloudAgentModelSelection(`{"mode":"video","channelId":"","channelModelKey":""}`, args); err == nil {
t.Fatal("explicit empty selection unexpectedly passed validation")
}
}
func TestCloudAgentProjectDefaultLogicalModelFillsWhenAvailable(t *testing.T) {
s, db, _ := agentMediaFixture(t)
cloudAgentProjectForDefaultModel(t, db, "logical-image")
s.routeCatalogTTL = time.Hour
imageSpec := CapabilitySpec{Version: 1, Capability: "image"}
s.routeCatalog = &routeCatalogSnapshot{
LoadedAt: time.Now(), Ordered: []string{"logical-image"}, Models: map[string]cachedLogicalModel{
"logical-image": {
Model: model.LogicalModel{ID: "logical-image", Name: "默认逻辑图片", Capability: "image", Enabled: true, PricePolicy: "unified"},
ProductSpec: imageSpec,
Routes: []cachedLogicalRoute{{
Route: model.LogicalModelRoute{ID: "logical-image-route", Enabled: true, Weight: 1},
CapabilitySpec: imageSpec,
ChannelModel: model.ChannelModel{ID: "video-cm", ChannelID: "channel", ModelKey: "seedance-test", Capability: "image"},
}},
},
},
}
run := &model.CloudAgentExecution{ID: "run-default-logical", UserID: "user"}
state := &cloudAgentRuntime{Request: CloudAgentRequest{CanvasID: "agent-canvas"}}
args := cloudAgentMediaArgs{Mode: "image"}
used, err := s.applyCloudAgentProjectDefaultModel(run, state, `{"mode":"image"}`, &args)
if err != nil || !used || args.LogicalModelID != "logical-image" || args.ChannelID != "" || args.ChannelModelKey != "" {
t.Fatalf("default logical selection = used:%v args:%+v err:%v", used, args, err)
}
}
func TestCloudAgentMediaMalformedJSONKeepsStrictFeedback(t *testing.T) {
for _, raw := range []string{`{"unknown":"private-sentinel"}`, `{"channelId":3}`, `{} {}`, `null`} {
call := cloudAgentCall{}

View File

@@ -85,31 +85,36 @@ type cloudAgentApproval struct {
Reason string `json:"reason,omitempty"`
}
type cloudAgentRuntime struct {
RuntimeRunID string `json:"-"`
Request CloudAgentRequest `json:"request"`
Policy cloudAgentPolicySnapshot `json:"policy"`
ParentID string `json:"parentId,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
CreativeAnchor cloudAgentCreativeAnchor `json:"creativeAnchor,omitempty"`
TextHistory []providerTextMessage `json:"textHistory,omitempty"`
Skills []cloudAgentSkill `json:"skills"`
Profile cloudAgentProfileSnapshot `json:"profile"`
ProfileReads map[string]bool `json:"profileReads,omitempty"`
ToolReadResults map[string]cloudAgentCachedToolResult `json:"toolReadResults,omitempty"`
ToolReadReplays map[string]int `json:"toolReadReplays,omitempty"`
readCacheExecution bool `json:"-"`
Canonical canonicalAgentRequest `json:"canonical"`
ActiveTaskID string `json:"activeTaskId"`
ActiveTextDraft string `json:"activeTextDraft,omitempty"`
MediaTaskID string `json:"mediaTaskId,omitempty"`
TaskIDs []string `json:"taskIds"`
Step int `json:"step"`
Generations int `json:"generations"`
VideoSeconds int `json:"videoSeconds"`
Calls []cloudAgentCall `json:"calls"`
CallIndex int `json:"callIndex"`
ToolRepairs map[string]cloudAgentToolRepair `json:"toolRepairs,omitempty"`
Approval *cloudAgentApproval `json:"approval,omitempty"`
RuntimeRunID string `json:"-"`
Request CloudAgentRequest `json:"request"`
Policy cloudAgentPolicySnapshot `json:"policy"`
ParentID string `json:"parentId,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
CreativeAnchor cloudAgentCreativeAnchor `json:"creativeAnchor,omitempty"`
TextHistory []providerTextMessage `json:"textHistory,omitempty"`
Skills []cloudAgentSkill `json:"skills"`
Profile cloudAgentProfileSnapshot `json:"profile"`
ProfileReads map[string]bool `json:"profileReads,omitempty"`
ToolReadResults map[string]cloudAgentCachedToolResult `json:"toolReadResults,omitempty"`
ToolReadReplays map[string]int `json:"toolReadReplays,omitempty"`
readCacheExecution bool `json:"-"`
Canonical canonicalAgentRequest `json:"canonical"`
ActiveTaskID string `json:"activeTaskId"`
ActiveTextDraft string `json:"activeTextDraft,omitempty"`
MediaTaskID string `json:"mediaTaskId,omitempty"`
TaskIDs []string `json:"taskIds"`
Step int `json:"step"`
Generations int `json:"generations"`
VideoSeconds int `json:"videoSeconds"`
Calls []cloudAgentCall `json:"calls"`
CallIndex int `json:"callIndex"`
ToolRepairs map[string]cloudAgentToolRepair `json:"toolRepairs,omitempty"`
Approval *cloudAgentApproval `json:"approval,omitempty"`
// AutoPreparedMedia is the durable admission checkpoint for permissionMode=auto.
// It prevents a worker restart between dry admission and billed submission from
// re-running admission or creating the draft node a second time.
AutoPreparedMedia *cloudAgentPreparedMedia `json:"autoPreparedMedia,omitempty"`
AutoPreparedCallHash string `json:"autoPreparedCallHash,omitempty"`
Decisions map[string]string `json:"decisions"`
DecisionSettings map[string]string `json:"decisionSettings,omitempty"`
DecisionPreparedHashes map[string]string `json:"decisionPreparedHashes,omitempty"`
@@ -436,10 +441,22 @@ func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgent
return errors.New("Agent runtime active task is not in task history")
}
if state.MediaTaskID != "" {
if !cloudAgentContainsString(state.TaskIDs, state.MediaTaskID) || state.CallIndex >= len(state.Calls) || state.Calls[state.CallIndex].Function.Name != "generate_media" {
if !cloudAgentContainsString(state.TaskIDs, state.MediaTaskID) || state.CallIndex >= len(state.Calls) || (state.Calls[state.CallIndex].Function.Name != "generate_media" && state.Calls[state.CallIndex].Function.Name != "image_layer_split") {
return errors.New("Agent runtime media task is not attached to current call")
}
}
if state.AutoPreparedMedia != nil {
if state.AutoPreparedCallHash == "" || state.CallIndex < 0 || state.CallIndex >= len(state.Calls) {
return errors.New("Agent runtime auto media preparation is invalid")
}
current := state.Calls[state.CallIndex]
if current.Function.Name != "generate_media" && current.Function.Name != "image_layer_split" {
return errors.New("Agent runtime auto media preparation is not attached to a media call")
}
if state.AutoPreparedCallHash != cloudAgentApprovalCallHash(current) {
return errors.New("Agent runtime auto media preparation does not match current call")
}
}
if state.Decisions == nil || state.Events == nil {
return errors.New("Agent runtime maps are missing")
}
@@ -714,7 +731,14 @@ func (s *Service) cloudAgentExecutionOutput(task *model.Task, initial cloudAgent
return nil, err
}
for _, order := range orders {
out.SpentCredits += float64(order.AmountMicrocredits) / float64(CreditScale)
// AmountMicrocredits is the reservation/quote, not necessarily the amount
// finally charged. A run can reserve 100000 microcredits and settle at
// 100000 microcredits (= 0.1 credits), or be refunded altogether. Never
// expose the reservation as spend, otherwise the Agent claims a charge
// that did not happen and budget/usage copy diverges from billing.
if order.Status == model.BillingStatusSettled {
out.SpentCredits += float64(order.ActualAmountMicrocredits) / float64(CreditScale)
}
}
return out, nil
}
@@ -1520,7 +1544,8 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
return s.terminateCloudAgent(run, "审批内容与待执行操作不一致,本轮已停止")
}
allowed := cloudAgentToolAllowed(state.Request, call.Function.Name)
if allowed && cloudAgentWrite(call.Function.Name) && (state.Request.PermissionMode == "request_approval" || call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split") && state.Approval == nil {
mediaTool := call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split"
if allowed && cloudAgentWrite(call.Function.Name) && (state.Request.PermissionMode == "request_approval" || mediaTool) && state.Approval == nil {
var plan *cloudAgentMediaPlan
var modelName string
var mediaRequest CreateTaskRequest
@@ -1536,20 +1561,99 @@ func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *c
if err != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
}
// Dry admission validates the selected model and prompt limits without a task or charge.
mediaPreparation = &creationTaskPreparation{}
req.creationPrepare = mediaPreparation
preparedTask, err = s.CreateTask(run.UserID, req)
if err != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
mediaRequest, plan = req, prepared
// A durable auto checkpoint already contains the exact admitted input and
// quote. Reusing it avoids a second dry admission after a worker restart.
if plan.Prepared == nil {
// Dry admission validates the selected model and prompt limits without a task or charge.
mediaPreparation = &creationTaskPreparation{}
req.creationPrepare = mediaPreparation
preparedTask, err = s.CreateTask(run.UserID, req)
if err != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
}
// Resolve model-owned defaults once during the dry admission. Both the
// eventual task and the canvas draft must use this exact resolved input;
// otherwise auto mode reports a false tool failure for omitted size or
// duration and makes the model spend another turn repairing its own call.
if err := applyCloudAgentResolvedMediaDefaults(&req, prepared, preparedTask); err != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
}
mediaRequest, plan = req, prepared
}
mediaRequest = req
plan = prepared
modelName, err = s.cloudAgentMediaModelName(plan.Args)
if err != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, err)
}
}
if plan != nil && state.Request.PermissionMode == "auto" {
if plan.Prepared != nil {
// The draft and quote were already checkpointed. Reuse them after a
// worker restart instead of dry-admitting and mutating the canvas again.
latest, err := s.repo.CloudAgent(run.UserID, run.ID)
if err != nil {
return err
}
fresh, err := cloudAgentDecode(latest)
if err != nil {
return err
}
return s.enqueueCloudAgentTask(latest, &fresh, mediaRequest, plan)
}
// Auto media is a direct, server-admitted write. Prepare the draft and
// its immutable quote in one checkpoint transaction, then release the
// lock before enqueueCloudAgentTask performs the billed submission.
s.storageMu.Lock()
err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, plan, nil, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil {
return err
}
canvas, err := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID)
if err != nil {
return err
}
doc, err := creationDocument(canvas.PayloadJSON)
if err != nil {
return err
}
plan.Args.SnapshotHash = cloudAgentMediaContentHash(doc)
raw, err := json.Marshal(plan.Args)
if err != nil {
return err
}
call.Function.Arguments = string(raw)
state.Calls[state.CallIndex] = call
preparedMedia, err := prepareCloudAgentMediaApproval(repo, run.UserID, doc, plan, mediaRequest, preparedTask, mediaPreparation.Order)
if err != nil {
return err
}
plan.Prepared = preparedMedia
state.AutoPreparedMedia = preparedMedia
state.AutoPreparedCallHash = cloudAgentApprovalCallHash(call)
return cloudAgentSave(current, state)
})
s.storageMu.Unlock()
if err != nil {
latest, readErr := s.repo.CloudAgent(run.UserID, run.ID)
if readErr != nil || latest.Revision != run.Revision {
return err
}
fresh, decodeErr := cloudAgentDecode(latest)
if decodeErr != nil {
return decodeErr
}
return s.cloudAgentMediaError(latest, &fresh, "admission", false, false, err)
}
latest, err := s.repo.CloudAgent(run.UserID, run.ID)
if err != nil {
return err
}
fresh, err := cloudAgentDecode(latest)
if err != nil {
return err
}
return s.enqueueCloudAgentTask(latest, &fresh, mediaRequest, plan)
}
s.storageMu.Lock()
defer s.storageMu.Unlock()
return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error {
@@ -1785,11 +1889,17 @@ func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *c
var prepared *cloudAgentPreparedMedia
approvalID, generationID := "", ""
if media != nil {
if state.Approval == nil || state.Approval.Prepared == nil {
prepared = media.Prepared
if prepared == nil && state.Approval != nil {
prepared = state.Approval.Prepared
if state.Approval != nil {
approvalID = state.Approval.ID
}
}
if prepared == nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, creationConflict("缺少已批准的生成准备态,请重新申请审批;未提交任务"))
}
prepared = state.Approval.Prepared
approvalID, generationID = state.Approval.ID, prepared.GenerationID
generationID = prepared.GenerationID
refs, refErr := cloudAgentPreparedReferences(s.repo, run.UserID, prepared)
if refErr != nil {
return s.cloudAgentMediaError(run, state, "admission", false, false, refErr)
@@ -1890,16 +2000,39 @@ func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *c
return err
}
if media != nil && prepared != nil {
if err := repo.TransferCloudAgentResourceLeases(run.UserID, approvalID, "task:"+task.ID, task.ID, prepared.Quote.ExpiresAt); err != nil {
return err
if approvalID != "" {
if err := repo.TransferCloudAgentResourceLeases(run.UserID, approvalID, "task:"+task.ID, task.ID, prepared.Quote.ExpiresAt); err != nil {
return err
}
} else {
// Auto execution has no approval owner to transfer from. Attach the
// resource lease directly to the billed task before it can run.
ids := make([]string, 0, len(prepared.ResourceSignatures))
for id := range prepared.ResourceSignatures {
ids = append(ids, id)
}
if err := repo.UpsertCloudAgentResourceLeases(run.UserID, run.ID, "task:"+task.ID, ids, prepared.Quote.ExpiresAt); err != nil {
return err
}
}
}
if media != nil {
// The prepared auto admission is single-use. Once the billed task and
// draft node are committed, a later retry must observe the task state,
// not reuse the old quote or create a second task.
state.AutoPreparedMedia = nil
state.AutoPreparedCallHash = ""
}
state.TaskIDs = append(state.TaskIDs, task.ID)
if media != nil {
state.MediaTaskID = task.ID
state.Generations++
state.VideoSeconds += media.Args.Duration
state.event(run.ID, "generation_task_created", map[string]any{"toolName": "generate_media", "taskId": task.ID, "nodeId": media.Args.NodeID, "title": media.Args.Title, "mode": media.Args.Mode, "canvasId": state.Request.CanvasID, "referenceNodeIds": media.Args.ReferenceNodeIDs, "text": "媒体节点与引用连线已创建,生成任务已提交"})
toolName := "generate_media"
if state.CallIndex >= 0 && state.CallIndex < len(state.Calls) && state.Calls[state.CallIndex].Function.Name != "" {
toolName = state.Calls[state.CallIndex].Function.Name
}
state.event(run.ID, "generation_task_created", map[string]any{"toolName": toolName, "taskId": task.ID, "nodeId": media.Args.NodeID, "title": media.Args.Title, "mode": media.Args.Mode, "canvasId": state.Request.CanvasID, "referenceNodeIds": media.Args.ReferenceNodeIDs, "text": "媒体节点与引用连线已创建,生成任务已提交"})
} else {
state.ActiveTaskID = task.ID
// 压缩调用不是本轮的一步:压完还要用压缩后的上下文继续步进,步数不该被它占掉。
@@ -1954,6 +2087,10 @@ func (s *Service) cloudAgentMediaError(run *model.CloudAgentExecution, state *cl
return cloudAgentSave(current, state)
}
cloudAgentRecordToolResult(current, state, state.Calls[state.CallIndex], map[string]any{"phase": phase, "taskSubmitted": submitted}, err)
// Any admission failure advances the call into the repair path. Do not let
// a prepared quote from the failed attempt leak into the corrected call.
state.AutoPreparedMedia = nil
state.AutoPreparedCallHash = ""
if submitted {
state.MediaTaskID = ""
}

View File

@@ -53,9 +53,29 @@ type cloudAgentTaskBillingFacts struct {
ReservedAmountMicrocredits int64 `json:"reservedAmountMicrocredits,omitempty"`
ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits,omitempty"`
RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits,omitempty"`
ReservedCredits float64 `json:"reservedCredits"`
ActualCredits float64 `json:"actualCredits"`
RefundedCredits float64 `json:"refundedCredits"`
ChargeState string `json:"chargeState"`
ChargeSummary string `json:"chargeSummary"`
StateAvailable bool `json:"stateAvailable"`
}
func cloudAgentChargeState(status model.BillingStatus) (string, string) {
switch status {
case model.BillingStatusSettled:
return "settled", "已结算,实际扣费以 actualCredits 为准"
case model.BillingStatusRefunded:
return "refunded", "已退款,本次不计入已消费"
case model.BillingStatusReserved, model.BillingStatusRunning:
return "pending", "仅预授权/执行中,尚未确认实际扣费"
case model.BillingStatusUncertain:
return "uncertain", "扣费状态待确认,不得称为已扣费"
default:
return "unknown", "扣费状态未知,不得称为已扣费"
}
}
func cloudAgentTaskDiagnostic(repo *repository.Repository, task *model.Task) map[string]any {
if task == nil {
return map[string]any{"taskStatus": "unavailable", "taskSubmitted": false, "submissionOutcome": "unavailable"}
@@ -98,6 +118,10 @@ func cloudAgentTaskDiagnostic(repo *repository.Repository, task *model.Task) map
facts.Billing.ReservedAmountMicrocredits = order.ReservedAmountMicrocredits
facts.Billing.ActualAmountMicrocredits = order.ActualAmountMicrocredits
facts.Billing.RefundedAmountMicrocredits = order.RefundedAmountMicrocredits
facts.Billing.ReservedCredits = float64(order.ReservedAmountMicrocredits) / float64(CreditScale)
facts.Billing.ActualCredits = float64(order.ActualAmountMicrocredits) / float64(CreditScale)
facts.Billing.RefundedCredits = float64(order.RefundedAmountMicrocredits) / float64(CreditScale)
facts.Billing.ChargeState, facts.Billing.ChargeSummary = cloudAgentChargeState(order.Status)
facts.Billing.StateAvailable = true
}
}

View File

@@ -83,3 +83,33 @@ func TestCloudAgentTaskDiagnosticRejectsMismatchedBillingOrderFacts(t *testing.T
t.Fatalf("cross-owner billing state leaked: %#v", billing)
}
}
func TestCloudAgentTaskDiagnosticSeparatesReservationFromActualCharge(t *testing.T) {
s, db, _, _ := creationTestService(t)
task := &model.Task{
ID: "task-settled", UserID: "user", ProjectID: "canvas", Status: model.TaskStatusSucceeded,
BillingOrderID: "order-settled", InputJSON: `{}`,
}
order := &model.BillingOrder{
ID: "order-settled", UserID: "user", TaskID: task.ID, Status: model.BillingStatusSettled,
ReservedAmountMicrocredits: 100_000, ActualAmountMicrocredits: 100_000,
}
if err := db.Create(task).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(order).Error; err != nil {
t.Fatal(err)
}
facts := cloudAgentTaskDiagnostic(s.repo, task)
billing, ok := facts["billing"].(map[string]any)
if !ok {
t.Fatalf("billing facts = %#v", facts["billing"])
}
if billing["reservedAmountMicrocredits"] != float64(100_000) || billing["actualAmountMicrocredits"] != float64(100_000) {
t.Fatalf("microcredit facts = %#v", billing)
}
if billing["reservedCredits"] != 0.1 || billing["actualCredits"] != 0.1 || billing["chargeState"] != "settled" {
t.Fatalf("credit facts = %#v", billing)
}
}

View File

@@ -509,7 +509,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
"topic", "category", "situation")
}
if req.PermissionMode != "read_only" && len(req.ContextScope) > 0 {
add("image_layer_split", "将图片按用户指定对象拆分为独立透明图层。参数与 generate_media 的图片生成参数一致,但 mode 固定为 image;这是生成型操作,必须进入现有媒体审批与计费链路,不能直接执行。", map[string]any{
add("image_layer_split", "将图片按用户指定对象拆分为独立透明图层。参数与 generate_media 的图片生成参数一致,但 mode 固定为 image;request_approval 进入界面独立审批,auto 由服务端准入成功后直接提交,不会再次弹出模型生成审批。", map[string]any{
"prompt": str("需要拆分的对象与透明背景要求"), "logicalModelId": str("selection.logicalModelId"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"),
"quality": str("模型支持的质量档位"), "snapshotHash": str("最近画布读取返回的 mediaSnapshotHash,可省略"), "nodeId": str("新的结果节点ID"), "title": str("结果节点名称"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("源图片节点ID")},
}, "prompt", "nodeId", "title", "referenceNodeIds")
@@ -582,7 +582,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
}, "snapshotHash")
}
if req.PermissionMode != "read_only" && len(req.ContextScope) > 0 {
add("generate_media", "提交媒体生成:先准备草稿、引用和独立审批,再提交收费任务(auto也需审批)。创建节点/改提示词/连线用 canvas_apply_ops。先读画布和模型目录并遵守其能力。已有任务或产物的节点不可覆盖;状态用 generation/task_get。sourceNodeId 为文本输入,referenceNodeIds 为媒体输入,referenceTransientIds 仅接受标注工具返回值,不接受URL。已提交失败要告知用户,重试须用户明确要求并重新审批。", map[string]any{
add("generate_media", "提交媒体生成:先准备草稿和引用;request_approval 需要用户确认,auto 在服务端完成模型、能力、价格、预算和资源校验后直接提交,不会再次弹出模型生成审批。auto 只在准入成功后提交。创建节点/改提示词/连线用 canvas_apply_ops。先读画布和模型目录并遵守其能力。已有任务或产物的节点不可覆盖;状态用 generation/task_get。sourceNodeId 为文本输入,referenceNodeIds 为媒体输入,referenceTransientIds 仅接受标注工具返回值,不接受URL。已提交失败要告知用户,重试须用户明确要求并重新审批。", map[string]any{
"mode": map[string]any{"type": "string", "enum": cloudAgentGenerationModeNames()}, "prompt": str("完整生成提示词;引用素材时在对应描述中使用 @图片1、@视频1、@音频1,各类型按 referenceNodeIds 中出现顺序独立编号,文本来源不占媒体编号。服务端会为遗漏的已选素材补齐引用标签,不推断素材用途"),
"logicalModelId": str("selection.logicalModelId;与channelId/channelModelKey互斥"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"),
"durationSeconds": map[string]any{"type": "integer", "minimum": 0}, "size": str("模型支持的画幅,例如9:16"), "quality": str("目录支持的分辨率或质量"), "videoGenerateAudio": map[string]any{"type": "boolean", "description": "是否生成音频,仅视频可用"},

View File

@@ -46,6 +46,7 @@ type PublicChannelModel struct {
Capability string `json:"capability"`
Protocol model.ChannelInterfaceType `json:"protocol"`
CapabilityConfig map[string]any `json:"capabilityConfig,omitempty"`
DefaultOptions map[string]any `json:"defaultOptions,omitempty"`
PriceTiers []PublicChannelModelPriceTier `json:"priceTiers"`
PricingMode string `json:"pricingMode"`
DisplayPrice *int64 `json:"displayPrice,omitempty"`
@@ -181,6 +182,17 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod
return PublicChannelModel{}, fmt.Errorf("投影渠道模型能力配置失败:%w", err)
}
}
var defaultOptions map[string]any
if normalized != nil {
spec, specErr := CapabilitySpecFromModelCapabilityConfig(normalized, cm.Capability)
if specErr != nil {
return PublicChannelModel{}, fmt.Errorf("投影渠道模型默认参数失败:%w", specErr)
}
defaultOptions, err = channelModelDefaultOptions(*cm, spec)
if err != nil {
return PublicChannelModel{}, err
}
}
return PublicChannelModel{
ID: cm.ID,
@@ -194,6 +206,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) (PublicChannelMod
Capability: cm.Capability,
Protocol: cm.Protocol,
CapabilityConfig: capabilityConfig,
DefaultOptions: defaultOptions,
PriceTiers: publicTiers,
PricingMode: pricingMode,
DisplayPrice: displayPrice,

View File

@@ -503,10 +503,13 @@ func newProjectUnit(projectID string, req CreateProjectUnitRequest, position int
if kind != model.ProjectUnitKindChapter && kind != model.ProjectUnitKindEpisode {
return model.ProjectUnit{}, BadAuthRequest("不支持的项目单元类型")
}
title := strings.TrimSpace(req.Title)
title := model.NormalizeProjectUnitTitle(req.Title)
if title == "" {
return model.ProjectUnit{}, BadAuthRequest("章节标题不能为空")
}
if !model.ValidProjectUnitTitle(title) {
return model.ProjectUnit{}, BadAuthRequest("章节标题不能超过 240 个字符")
}
if position < 0 {
position = 0
}
@@ -524,7 +527,10 @@ func (s *Service) UpdateProjectUnit(userID string, projectID string, unitID stri
return model.ProjectUnit{}, err
}
sourceChanged := unit.SourceText != req.SourceText
if title := strings.TrimSpace(req.Title); title != "" {
if title := model.NormalizeProjectUnitTitle(req.Title); title != "" {
if !model.ValidProjectUnitTitle(title) {
return model.ProjectUnit{}, BadAuthRequest("章节标题不能超过 240 个字符")
}
unit.Title = title
}
unit.SourceText = req.SourceText

View File

@@ -11,7 +11,7 @@ import (
"gorm.io/gorm"
)
const CurrentSchemaVersion int64 = 37
const CurrentSchemaVersion int64 = 38
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
@@ -25,6 +25,7 @@ const creationRuntimeChecksum = "sha256:creation-runtime-v10-20260909"
const authNotificationsChecksum = "sha256:auth-notifications-v35-20260924"
const cloudAgentGeminiCacheChecksum = "sha256:cloud-agent-gemini-cache-v36-20260924"
const cloudAgentGeminiCacheIdentityChecksum = "sha256:cloud-agent-gemini-cache-identity-v37-20260925"
const prefixedIDSequenceReconcileChecksum = "sha256:prefixed-id-sequence-reconcile-v38-20260926"
const postgresSchemaMigrationLockID int64 = 73123910420260830
@@ -125,6 +126,14 @@ var schemaMigrations = []migration{
return tx.AutoMigrate(&model.CloudAgentGeminiCache{})
}},
{version: 37, name: "cloud_agent_gemini_cache_identity", checksum: cloudAgentGeminiCacheIdentityChecksum, apply: migrateCloudAgentGeminiCacheIdentity},
{version: 38, name: "prefixed_id_sequence_reconcile", checksum: prefixedIDSequenceReconcileChecksum, apply: migratePrefixedIDSequenceReconcile},
}
func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error {
if err := tx.AutoMigrate(&model.IDSequence{}); err != nil {
return fmt.Errorf("创建可读 ID 序列表:%w", err)
}
return reconcilePrefixedIDSequences(tx)
}
func migrateCloudAgentGeminiCacheIdentity(tx *gorm.DB) error {

View File

@@ -0,0 +1,224 @@
package database
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// prefixedIDDefinition is the single source of truth for the readable IDs that
// are allocated from id_sequences. The business table is used to reconcile a
// sequence after a SQLite -> PostgreSQL copy or an interrupted write.
type prefixedIDDefinition struct {
prefix string
model any
}
var prefixedIDDefinitions = []prefixedIDDefinition{
{prefix: "CHANNEL", model: &model.ModelChannel{}},
{prefix: "MODEL", model: &model.ChannelModel{}},
{prefix: "PTIER", model: &model.ChannelModelPriceTier{}},
{prefix: "ATTEMPT", model: &model.RouteAttempt{}},
{prefix: "LMODEL", model: &model.LogicalModel{}},
{prefix: "REVISION", model: &model.LogicalModelRevision{}},
{prefix: "ROUTE", model: &model.LogicalModelRoute{}},
}
// ReconcilePrefixedIDSequences advances all known readable-ID sequences to at
// least the largest valid business ID. It never moves a sequence backwards.
// Soft-deleted rows are included deliberately: their IDs remain reserved.
func ReconcilePrefixedIDSequences(db *gorm.DB) error {
if db == nil {
return errors.New("database handle is nil")
}
return db.Transaction(func(tx *gorm.DB) error {
return reconcilePrefixedIDSequences(tx)
})
}
func reconcilePrefixedIDSequences(tx *gorm.DB) error {
for _, definition := range prefixedIDDefinitions {
if !tx.Migrator().HasTable(definition.model) {
continue
}
maximum, err := maxPrefixedBusinessID(tx, definition)
if err != nil {
return fmt.Errorf("读取 %s 业务 ID:%w", definition.prefix, err)
}
if err := ensureSequenceRow(tx, definition.prefix); err != nil {
return fmt.Errorf("初始化 %s 序列:%w", definition.prefix, err)
}
item, err := lockedSequence(tx, definition.prefix)
if err != nil {
return fmt.Errorf("读取 %s 序列:%w", definition.prefix, err)
}
if item.Value >= maximum {
continue
}
if err := tx.Model(&model.IDSequence{}).Where("name = ?", item.Name).Updates(map[string]any{
"value": maximum,
"updated_at": time.Now(),
}).Error; err != nil {
return fmt.Errorf("校准 %s 序列到 %d:%w", definition.prefix, maximum, err)
}
}
return nil
}
// AllocatePrefixedID allocates the next readable ID. For known prefixes it
// first reconciles the sequence with the business table and checks the
// candidate against that table, so stale sequences and soft-deleted rows
// cannot produce duplicate primary keys.
func AllocatePrefixedID(db *gorm.DB, prefix string) (string, error) {
if db == nil {
return "", errors.New("database handle is nil")
}
var allocated string
if err := db.Transaction(func(tx *gorm.DB) error {
var err error
allocated, err = allocatePrefixedID(tx, prefix)
return err
}); err != nil {
return "", err
}
return allocated, nil
}
func allocatePrefixedID(tx *gorm.DB, rawPrefix string) (string, error) {
prefix := normalizeIDPrefix(rawPrefix)
if prefix == "" || len(prefix) > 16 {
return "", errors.New("invalid id prefix")
}
if err := ensureSequenceRow(tx, prefix); err != nil {
return "", err
}
item, err := lockedSequence(tx, prefix)
if err != nil {
return "", err
}
definition, known := prefixedIDDefinitionFor(prefix)
candidateValue := item.Value
if known {
if !tx.Migrator().HasTable(definition.model) {
return "", fmt.Errorf("ID 前缀 %s 对应的业务表不存在", prefix)
}
maximum, err := maxPrefixedBusinessID(tx, definition)
if err != nil {
return "", fmt.Errorf("读取 %s 业务 ID:%w", prefix, err)
}
if candidateValue < maximum {
candidateValue = maximum
}
}
if candidateValue == math.MaxInt64 {
return "", fmt.Errorf("ID 前缀 %s 序列已达到上限", prefix)
}
for {
candidateValue++
candidate := fmt.Sprintf("%s_%06d", prefix, candidateValue)
if !known {
if err := updateSequence(tx, item.Name, candidateValue); err != nil {
return "", err
}
return candidate, nil
}
var count int64
if err := tx.Unscoped().Model(definition.model).Where("id = ?", candidate).Count(&count).Error; err != nil {
return "", fmt.Errorf("检查 %s 候选 ID:%w", prefix, err)
}
if count != 0 {
if candidateValue == math.MaxInt64 {
return "", fmt.Errorf("ID 前缀 %s 序列已达到上限", prefix)
}
continue
}
if err := updateSequence(tx, item.Name, candidateValue); err != nil {
return "", err
}
return candidate, nil
}
}
func normalizeIDPrefix(raw string) string {
return strings.ToUpper(strings.TrimSpace(raw))
}
func prefixedIDDefinitionFor(prefix string) (prefixedIDDefinition, bool) {
for _, definition := range prefixedIDDefinitions {
if definition.prefix == prefix {
return definition, true
}
}
return prefixedIDDefinition{}, false
}
func ensureSequenceRow(tx *gorm.DB, prefix string) error {
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.IDSequence{
Name: "id:" + prefix,
UpdatedAt: time.Now(),
}).Error
}
func lockedSequence(tx *gorm.DB, prefix string) (model.IDSequence, error) {
query := tx.Model(&model.IDSequence{}).Where("name = ?", "id:"+prefix)
if tx.Dialector.Name() == "postgres" {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
var item model.IDSequence
if err := query.First(&item).Error; err != nil {
return model.IDSequence{}, err
}
return item, nil
}
func updateSequence(tx *gorm.DB, name string, value int64) error {
return tx.Model(&model.IDSequence{}).Where("name = ?", name).Updates(map[string]any{
"value": value,
"updated_at": time.Now(),
}).Error
}
func maxPrefixedBusinessID(tx *gorm.DB, definition prefixedIDDefinition) (int64, error) {
var ids []string
if err := tx.Unscoped().Model(definition.model).Select("id").Pluck("id", &ids).Error; err != nil {
return 0, err
}
prefix := definition.prefix + "_"
var maximum int64
for _, id := range ids {
if !strings.HasPrefix(id, prefix) {
continue
}
digits := strings.TrimPrefix(id, prefix)
if digits == "" {
continue
}
for _, character := range digits {
if character < '0' || character > '9' {
digits = ""
break
}
}
if digits == "" {
continue
}
value, err := strconv.ParseInt(digits, 10, 64)
if err != nil {
continue
}
if value > maximum {
maximum = value
}
}
return maximum, nil
}

View File

@@ -10,11 +10,22 @@ import (
var projectUnitHTMLTagPattern = regexp.MustCompile(`<[^>]+>`)
const ProjectUnitTitleMaxRunes = 240
func ProjectUnitWordCount(sourceText string) int {
plainText := projectUnitHTMLTagPattern.ReplaceAllString(sourceText, "")
return utf8.RuneCountInString(strings.TrimSpace(html.UnescapeString(plainText)))
}
func NormalizeProjectUnitTitle(value string) string {
return strings.TrimSpace(value)
}
func ValidProjectUnitTitle(value string) bool {
title := NormalizeProjectUnitTitle(value)
return title != "" && utf8.RuneCountInString(title) <= ProjectUnitTitleMaxRunes
}
const AssetIDMaxLength = 80
type Resource struct {

View File

@@ -1,16 +1,16 @@
---
id: cloud-agent-media
version: 3
version: 4
---
# 影策 Cloud Agent 媒体策略
生成前读取节点能力和当前画布,向 model_list 传实际 mode 与 referenceNodeIds,按真实参考素材筛选模型。多图生视频不能选择仅文生视频模型,也不能丢弃参考图来迁就模型。返回的 options / profiles 是配置能力,不凭模型名称猜测;同一配置必须同时满足参考数量、时长、画幅和音频要求,不能拼接不同 profiles 的能力。空候选时说明能力缺口,不编造可用模型;素材或模式改变后旧的筛选结果不再适用。
用户要求生成且参数齐备时,本轮直接调用 generate_media 进入独立审批,不把仅创建节点、连线或填写提示词说成已提交。用户仅要求改词或准备草稿时不要擅自生成。已有未提交草稿应优先续用原节点:无任务的空白媒体占位节点、当前运行草稿、已结束且清理完成运行的遗留草稿均需最新快照和新的审批。另一运行仍在处理的草稿先完成或取消原运行;已有任务或成品不可覆盖。不要通过不断换新 ID 逃避错误,不把所有旧节点都误认为不可操作。
用户要求生成且参数齐备时,本轮直接调用 generate_media。request_approval 会进入界面独立审批;auto 会由服务端完成模型、能力、价格、预算和资源准入后直接提交,不会再产生一张模型生成审批卡。不要把仅创建节点、连线或填写提示词说成已提交。用户仅要求改词或准备草稿时不要擅自生成。已有未提交草稿应优先续用原节点:无任务的空白媒体占位节点、当前运行草稿、已结束且清理完成运行的遗留草稿均需最新快照;request_approval 需要新的审批,auto 由服务端重新准入。另一运行仍在处理的草稿先完成或取消原运行;已有任务或成品不可覆盖。不要通过不断换新 ID 逃避错误,不把所有旧节点都误认为不可操作。
媒体生成先区分参数选择与收费授权。用户明确指定的模型、画幅、时长、音频必须遵守;用户说“随便”“你决定”“按默认”时允许安全默认,不要求四项逐个确认:从当前目录选择支持参考方式、参数且预算可覆盖的模型,优先用户最近明确使用且仍可用的模型,否则选择价格清楚的低成本可用项。画幅优先匹配参考图原始宽高比且必须在模型支持范围内,否则用目录默认;时长用目录默认,不编造;模型不支持音频且用户未要求声音时直接用默片。
用户对上一条明确推荐方案说“可以”“开始吧”,即接受该方案的参数,不要求回复特定确认口令。只有引用对象不明确、用户硬性要求与能力冲突、无有效默认值或预算不足时,一次性询问真正缺少的事项。选择后简短说明实际参数和默认来源,并立即创建草稿交给审批卡,不添加额外文字确认轮次。
用户对上一条明确推荐方案说“可以”“开始吧”,即接受该方案的参数,不要求回复特定确认口令。只有引用对象不明确、用户硬性要求与能力冲突、无有效默认值或预算不足时,一次性询问真正缺少的事项。选择后简短说明实际参数和默认来源;request_approval 创建草稿交给审批卡,auto 直接提交并用 task_get/generation 核实结果,不添加额外文字确认轮次。
创建媒体草稿及连线不等于收费授权;所有模式都必须等待界面独立审批。“随便”“开始”和默认参数都不能跳过审批。已提交的收费任务失败不得自动重提、换模型或盲重试;把失败原因告诉用户并结束本步,不要把整轮对话停掉。
创建媒体草稿及连线不等于收费授权;request_approval 必须等待界面独立审批,auto 只有在服务端准入成功后才提交,不能再等待或伪造审批事件。“随便”“开始”和默认参数都不能绕过服务端准入。已提交的收费任务失败不得自动重提、换模型或盲重试;把失败原因告诉用户并结束本步,不要把整轮对话停掉。查询计费时,authorizedChargeMicrocredits / chargeLimitMicrocredits 是预授权或上限,不是实际消费;只有 billing.status=settled 时才使用 actualCredits(或 actualAmountMicrocredits)描述已扣费,reservedCredits 不能当作已扣费。

View File

@@ -10,12 +10,21 @@ func TestLoadAgentPoliciesUsesDocumentMetadata(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if system.ID != "cloud-agent-system" || system.Version != 10 || media.ID != "cloud-agent-media" || media.Version != 3 {
if system.ID != "cloud-agent-system" || system.Version != 10 || media.ID != "cloud-agent-media" || media.Version != 4 {
t.Fatalf("unexpected policy metadata: system=%+v media=%+v", system, media)
}
if strings.Contains(system.Text, "id: cloud-agent-system") || !strings.HasPrefix(system.Text, "# 影策 Cloud Agent") {
t.Fatalf("metadata leaked into compiled policy body: %q", system.Text)
}
for _, phrase := range []string{
"auto 会由服务端完成模型、能力、价格、预算和资源准入后直接提交",
"request_approval 必须等待界面独立审批",
"authorizedChargeMicrocredits / chargeLimitMicrocredits 是预授权或上限,不是实际消费",
} {
if !strings.Contains(media.Text, phrase) {
t.Fatalf("media policy lost required contract phrase %q", phrase)
}
}
}
func TestParsePolicyDocumentRejectsInvalidMetadata(t *testing.T) {

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"infinite-canvas/backend/internal/database"
"infinite-canvas/backend/internal/model"
"gorm.io/gorm"
@@ -73,32 +74,7 @@ func (r *Repository) ReleaseTaskLease(id string, owner string) error {
// NextPrefixedID 在数据库事务中递增序列,避免 UUID/父子字符串拼接导致的不可读和不可排序 ID。
// prefix 只决定展示前缀,关联关系仍由独立外键维护。
func (r *Repository) NextPrefixedID(prefix string) (string, error) {
return r.nextPrefixedID(r.db, prefix)
}
func (r *Repository) nextPrefixedID(db *gorm.DB, prefix string) (string, error) {
prefix = strings.ToUpper(strings.TrimSpace(prefix))
if prefix == "" || len(prefix) > 16 {
return "", errors.New("invalid id prefix")
}
sequence := "id:" + prefix
var item model.IDSequence
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.IDSequence{Name: sequence, UpdatedAt: time.Now()}).Error; err != nil {
return err
}
if err := tx.Model(&model.IDSequence{}).Where("name = ?", sequence).Updates(map[string]any{
"value": gorm.Expr("value + ?", 1),
"updated_at": time.Now(),
}).Error; err != nil {
return err
}
return tx.First(&item, "name = ?", sequence).Error
})
if err != nil {
return "", err
}
return fmt.Sprintf("%s_%06d", prefix, item.Value), nil
return database.AllocatePrefixedID(r.db, prefix)
}
func (r *Repository) UserStorageUsage(userID string) (UserStorageUsage, error) {

View File

@@ -51,9 +51,9 @@ func (r *Repository) SkillFiles(versionID string) ([]model.SkillFile, error) {
return files, err
}
func (r *Repository) SkillsForPackageEnsure(userSource int) ([]model.Skill, error) {
func (r *Repository) SkillsForPackageEnsure() ([]model.Skill, error) {
var skills []model.Skill
err := r.db.Where("status = ? AND (current_version_id = '' OR source <> ?)", 1, userSource).Find(&skills).Error
err := r.db.Where("status = ?", 1).Find(&skills).Error
return skills, err
}

View File

@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
@@ -104,6 +105,14 @@ type skillPackageArchive struct {
TotalBytes int64
}
type skillPackageSnapshot struct {
skill *model.Skill
version *model.SkillVersion
files []model.SkillFile
legacy bool
contents map[string][]byte
}
type githubSkillSpec struct {
Owner string
Repo string
@@ -112,27 +121,54 @@ type githubSkillSpec struct {
}
func (s *Service) EnsureSkillPackages() error {
skills, err := s.repo.SkillsForPackageEnsure(skillSourceUser)
skills, err := s.repo.SkillsForPackageEnsure()
if err != nil {
return err
}
for index := range skills {
skill := &skills[index]
// Legacy fields are only fallbacks for package metadata. Bound them at
// this migration boundary without rewriting source fields or relaxing uploads.
archive, err := archiveFromMarkdown([]byte(skill.Instruction), truncateSkillMetadata(skill.Name, 80), truncateSkillMetadata(skill.Description, 500))
if err != nil {
return fmt.Errorf("迁移技能 %s 文件包失败: %w", skill.ID, err)
}
if skill.CurrentVersionID != "" && skill.ContentHash == archive.ContentHash {
version, files, healthErr := s.skillPackageHealth(skill)
if healthErr == nil {
if err := s.syncSkillPackageMetadata(skill, version, files); err != nil {
return fmt.Errorf("同步技能 %s 文件包元数据失败: %w", skill.ID, err)
}
// User-installed ZIP/GitHub skills are authoritative in skill_files and
// must never be replaced by the legacy instruction column at startup.
if skill.Source == skillSourceUser {
continue
}
if strings.TrimSpace(skill.Instruction) == "" {
continue
}
archive, archiveErr := archiveFromMarkdown([]byte(skill.Instruction), truncateSkillMetadata(skill.Name, 80), truncateSkillMetadata(skill.Description, 500))
if archiveErr != nil {
log.Printf("技能 %s 的内置正文无法刷新文件包:%v", skill.ID, archiveErr)
continue
}
if version.ContentHash == archive.ContentHash {
continue
}
if err := s.addSkillArchiveVersion(skill, archive, skillSourceTypeForRepair(skill), skill.SourceURL, skill.SourceRef, skill.SourceSubdir, skill.SourceCommit, skill.AutoUpdate); err != nil {
return fmt.Errorf("刷新技能 %s 文件包失败: %w", skill.ID, err)
}
continue
}
sourceType := "builtin"
if skill.Source == skillSourceUser {
sourceType = "markdown"
// A legacy row may contain only skills.instruction. Rebuild that single
// entry when package metadata, files, or the archive is missing. If no
// source body exists, keep the service available and leave a diagnostic
// instead of failing every user request during boot.
if strings.TrimSpace(skill.Instruction) == "" {
log.Printf("技能 %s 文件包不完整且没有 legacy instruction,跳过自动修复:%v", skill.ID, healthErr)
continue
}
if err := s.addSkillArchiveVersion(skill, archive, sourceType, "", "", "", "", false); err != nil {
return fmt.Errorf("保存技能 %s 文件包失败: %w", skill.ID, err)
archive, archiveErr := archiveFromMarkdown([]byte(skill.Instruction), truncateSkillMetadata(skill.Name, 80), truncateSkillMetadata(skill.Description, 500))
if archiveErr != nil {
log.Printf("技能 %s 文件包损坏且 legacy instruction 无法重建:%v", skill.ID, archiveErr)
continue
}
if err := s.addSkillArchiveVersion(skill, archive, skillSourceTypeForRepair(skill), skill.SourceURL, skill.SourceRef, skill.SourceSubdir, skill.SourceCommit, skill.AutoUpdate); err != nil {
return fmt.Errorf("修复技能 %s 文件包失败: %w", skill.ID, err)
}
}
return nil
@@ -422,25 +458,19 @@ func (s *Service) persistSkillArchive(skillID string, versionID string, archive
}
func (s *Service) SkillPackageFiles(userID string, skillID string) ([]SkillPackageFileItem, error) {
skill, err := s.visibleSkill(userID, skillID)
snapshot, err := s.loadSkillPackageSnapshot(userID, skillID)
if err != nil {
return nil, err
}
files, err := s.repo.SkillFiles(skill.CurrentVersionID)
if err != nil {
return nil, err
}
items := make([]SkillPackageFileItem, 0, len(files))
for _, file := range files {
items := make([]SkillPackageFileItem, 0, len(snapshot.files))
for _, file := range snapshot.files {
items = append(items, skillFileItem(file))
}
return items, nil
}
func (s *Service) SkillPackageFile(userID string, skillID string, filePath string) (*SkillPackageFileContent, error) {
skill, version, file, content, err := s.readSkillPackageFile(userID, skillID, filePath)
_ = skill
_ = version
_, file, content, err := s.readSkillPackageSnapshotFile(userID, skillID, filePath)
if err != nil {
return nil, err
}
@@ -455,7 +485,7 @@ func (s *Service) SkillPackageFile(userID string, skillID string, filePath strin
}
func (s *Service) SkillPackageRawFile(userID string, skillID string, filePath string) ([]byte, string, string, error) {
_, _, file, content, err := s.readSkillPackageFile(userID, skillID, filePath)
_, file, content, err := s.readSkillPackageSnapshotFile(userID, skillID, filePath)
if err != nil {
return nil, "", "", err
}
@@ -463,21 +493,13 @@ func (s *Service) SkillPackageRawFile(userID string, skillID string, filePath st
}
func (s *Service) SkillPackageBundle(userID string, skillID string) (*SkillPackageBundle, error) {
skill, err := s.visibleSkill(userID, skillID)
snapshot, err := s.loadSkillPackageSnapshot(userID, skillID)
if err != nil {
return nil, err
}
version, err := s.repo.SkillVersion(skill.CurrentVersionID)
if err != nil {
return nil, err
}
files, err := s.repo.SkillFiles(version.ID)
if err != nil {
return nil, err
}
bundle := &SkillPackageBundle{SkillID: skill.ID, Name: skill.Name, Description: skill.Description, VersionID: version.ID, Version: version.VersionLabel, ContentHash: version.ContentHash, Files: make([]SkillPackageBundleFile, 0, len(files))}
for _, file := range files {
content, err := s.readSkillArchiveEntry(version, file.Path)
bundle := &SkillPackageBundle{SkillID: snapshot.skill.ID, Name: snapshot.skill.Name, Description: snapshot.skill.Description, VersionID: snapshot.version.ID, Version: snapshot.version.VersionLabel, ContentHash: snapshot.version.ContentHash, Files: make([]SkillPackageBundleFile, 0, len(snapshot.files))}
for _, file := range snapshot.files {
content, err := s.readSkillPackageSnapshotEntry(snapshot, file.Path)
if err != nil {
return nil, err
}
@@ -487,7 +509,7 @@ func (s *Service) SkillPackageBundle(userID string, skillID string) (*SkillPacka
}
func (s *Service) SearchSkillPackage(userID string, skillID string, query string) ([]SkillFileSearchResult, error) {
skill, err := s.visibleSkill(userID, skillID)
snapshot, err := s.loadSkillPackageSnapshot(userID, skillID)
if err != nil {
return nil, err
}
@@ -495,21 +517,13 @@ func (s *Service) SearchSkillPackage(userID string, skillID string, query string
if query == "" || utf8.RuneCountInString(query) > 120 {
return nil, kernel.BadAuthRequest("搜索关键词必须为 1-120 个字符")
}
version, err := s.repo.SkillVersion(skill.CurrentVersionID)
if err != nil {
return nil, err
}
files, err := s.repo.SkillFiles(version.ID)
if err != nil {
return nil, err
}
needle := strings.ToLower(query)
results := make([]SkillFileSearchResult, 0, 20)
for _, file := range files {
for _, file := range snapshot.files {
if len(results) >= 50 || !isPreviewText(file.MimeType, file.Path) || file.Size > maxSkillPreviewBytes {
continue
}
content, err := s.readSkillArchiveEntry(version, file.Path)
content, err := s.readSkillPackageSnapshotEntry(snapshot, file.Path)
if err != nil {
return nil, err
}
@@ -525,31 +539,230 @@ func (s *Service) SearchSkillPackage(userID string, skillID string, query string
return results, nil
}
func (s *Service) readSkillPackageFile(userID string, skillID string, filePath string) (*model.Skill, *model.SkillVersion, *model.SkillFile, []byte, error) {
skill, err := s.visibleSkill(userID, skillID)
func (s *Service) readSkillPackageSnapshotFile(userID string, skillID string, filePath string) (*skillPackageSnapshot, *model.SkillFile, []byte, error) {
snapshot, err := s.loadSkillPackageSnapshot(userID, skillID)
if err != nil {
return nil, nil, nil, nil, err
return nil, nil, nil, err
}
filePath, err = normalizeSkillPath(filePath)
if err != nil {
return nil, nil, nil, nil, err
return nil, nil, nil, err
}
for index := range snapshot.files {
if snapshot.files[index].Path != filePath {
continue
}
content, err := s.readSkillPackageSnapshotEntry(snapshot, filePath)
return snapshot, &snapshot.files[index], content, err
}
return nil, nil, nil, kernel.BadAuthRequest("技能文件不存在")
}
func (s *Service) loadSkillPackageSnapshot(userID string, skillID string) (*skillPackageSnapshot, error) {
skill, err := s.visibleSkill(userID, skillID)
if err != nil {
return nil, err
}
version, err := s.repo.SkillVersion(skill.CurrentVersionID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return legacySkillPackageSnapshot(skill)
}
if err != nil {
return nil, nil, nil, nil, err
return nil, err
}
if version.SkillID != skill.ID {
return nil, fmt.Errorf("技能 %s 当前版本归属不一致", skill.ID)
}
files, err := s.repo.SkillFiles(version.ID)
if err != nil {
return nil, nil, nil, nil, err
return nil, err
}
for index := range files {
if files[index].Path != filePath {
if len(files) == 0 {
return legacySkillPackageSnapshot(skill)
}
if err := validateSkillPackageSnapshot(s, skill, version, files); err != nil {
return nil, err
}
return &skillPackageSnapshot{skill: skill, version: version, files: files}, nil
}
func (s *Service) readSkillPackageSnapshotEntry(snapshot *skillPackageSnapshot, filePath string) ([]byte, error) {
if snapshot.contents != nil {
content, ok := snapshot.contents[filePath]
if !ok {
return nil, kernel.BadAuthRequest("技能文件不存在")
}
return content, nil
}
return s.readSkillArchiveEntry(snapshot.version, filePath)
}
func legacySkillPackageSnapshot(skill *model.Skill) (*skillPackageSnapshot, error) {
if strings.TrimSpace(skill.Instruction) == "" {
return nil, fmt.Errorf("技能 %s 缺少当前版本、文件清单和 legacy instruction", skill.ID)
}
content := []byte(skill.Instruction)
files := map[string][]byte{"SKILL.md": content}
digest := sha256.Sum256(content)
hash, total := contentHashAndSize(files)
versionID := strings.TrimSpace(skill.CurrentVersionID)
versionLabel := strings.TrimSpace(skill.VersionLabel)
if versionLabel == "" {
versionLabel = "legacy"
}
version := &model.SkillVersion{ID: versionID, SkillID: skill.ID, VersionLabel: versionLabel, ContentHash: hash, EntryPath: "SKILL.md", FileCount: 1, TotalBytes: total}
file := model.SkillFile{SkillVersionID: versionID, Path: "SKILL.md", Kind: "markdown", MimeType: skillFileMime("SKILL.md", content), Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])}
return &skillPackageSnapshot{skill: skill, version: version, files: []model.SkillFile{file}, legacy: true, contents: files}, nil
}
func skillSourceTypeForRepair(skill *model.Skill) string {
if value := strings.TrimSpace(skill.SourceType); value != "" {
return value
}
if skill.Source == skillSourceUser {
return "markdown"
}
return "builtin"
}
func (s *Service) skillPackageHealth(skill *model.Skill) (*model.SkillVersion, []model.SkillFile, error) {
if strings.TrimSpace(skill.CurrentVersionID) == "" {
return nil, nil, gorm.ErrRecordNotFound
}
version, err := s.repo.SkillVersion(skill.CurrentVersionID)
if err != nil {
return nil, nil, err
}
if version.SkillID != skill.ID {
return nil, nil, fmt.Errorf("当前版本归属技能不一致")
}
files, err := s.repo.SkillFiles(version.ID)
if err != nil {
return nil, nil, err
}
if err := validateSkillPackageSnapshot(s, skill, version, files); err != nil {
return nil, nil, err
}
return version, files, nil
}
func (s *Service) syncSkillPackageMetadata(skill *model.Skill, version *model.SkillVersion, files []model.SkillFile) error {
changed := skill.CurrentVersionID != version.ID || skill.VersionLabel != version.VersionLabel || skill.ContentHash != version.ContentHash || skill.FileCount != len(files) || skill.TotalBytes != version.TotalBytes
if !changed {
return nil
}
skill.CurrentVersionID = version.ID
skill.VersionLabel = version.VersionLabel
skill.ContentHash = version.ContentHash
skill.FileCount = len(files)
skill.TotalBytes = version.TotalBytes
return s.repo.SaveSkill(skill)
}
func validateSkillPackageSnapshot(s *Service, skill *model.Skill, version *model.SkillVersion, files []model.SkillFile) error {
if len(files) == 0 || version.FileCount != len(files) || version.TotalBytes < 0 {
return fmt.Errorf("技能 %s 文件元数据不完整", skill.ID)
}
if skill.ContentHash != "" && skill.ContentHash != version.ContentHash {
return fmt.Errorf("技能 %s 内容摘要与当前版本不一致", skill.ID)
}
if skill.FileCount != 0 && skill.FileCount != len(files) {
return fmt.Errorf("技能 %s 文件数量与当前版本不一致", skill.ID)
}
if skill.TotalBytes != 0 && skill.TotalBytes != version.TotalBytes {
return fmt.Errorf("技能 %s 文件大小与当前版本不一致", skill.ID)
}
if version.PackageKey == "" {
return fmt.Errorf("技能 %s 当前版本缺少 ZIP 包", skill.ID)
}
contents, err := readSkillArchiveEntries(s.dataDir, version.PackageKey)
if err != nil {
return fmt.Errorf("读取技能 %s ZIP 包失败: %w", skill.ID, err)
}
byPath := make(map[string]model.SkillFile, len(files))
var total int64
for _, file := range files {
if strings.TrimSpace(file.Path) == "" {
return fmt.Errorf("技能 %s 文件清单存在空路径", skill.ID)
}
if version.EntryPath != "" && version.EntryPath != "SKILL.md" {
return fmt.Errorf("技能 %s 当前入口不是 SKILL.md", skill.ID)
}
if _, exists := byPath[file.Path]; exists {
return fmt.Errorf("技能 %s 文件清单包含重复路径 %s", skill.ID, file.Path)
}
byPath[file.Path] = file
content, ok := contents[file.Path]
if !ok {
return fmt.Errorf("技能 %s ZIP 缺少数据库文件 %s", skill.ID, file.Path)
}
digest := sha256.Sum256(content)
if file.Size != int64(len(content)) || file.SHA256 != hex.EncodeToString(digest[:]) {
return fmt.Errorf("技能 %s 文件 %s 元数据与 ZIP 不一致", skill.ID, file.Path)
}
total += int64(len(content))
}
if _, ok := byPath["SKILL.md"]; !ok {
return fmt.Errorf("技能 %s 文件清单缺少 SKILL.md", skill.ID)
}
if len(contents) != len(files) || total != version.TotalBytes {
return fmt.Errorf("技能 %s ZIP 文件数量或大小与版本元数据不一致", skill.ID)
}
hash, _ := contentHashAndSize(contents)
if version.ContentHash == "" || hash != version.ContentHash {
return fmt.Errorf("技能 %s ZIP 内容摘要与版本元数据不一致", skill.ID)
}
return nil
}
func readSkillArchiveEntries(dataDir string, packageKey string) (map[string][]byte, error) {
absolute := filepath.Join(dataDir, "skill-packages", filepath.FromSlash(packageKey))
reader, err := zip.OpenReader(absolute)
if err != nil {
return nil, err
}
defer reader.Close()
if len(reader.File) > maxSkillPackageFiles+64 {
return nil, kernel.BadAuthRequest("技能包文件数量异常")
}
contents := make(map[string][]byte, len(reader.File))
var total int64
for _, entry := range reader.File {
if entry.FileInfo().IsDir() {
continue
}
content, err := s.readSkillArchiveEntry(version, filePath)
return skill, version, &files[index], content, err
filePath, err := normalizeSkillPath(entry.Name)
if err != nil {
return nil, err
}
if _, exists := contents[filePath]; exists {
return nil, kernel.BadAuthRequest("技能包包含重复文件路径")
}
if entry.UncompressedSize64 > maxSkillFileBytes {
return nil, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB")
}
file, err := entry.Open()
if err != nil {
return nil, err
}
content, readErr := io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1))
closeErr := file.Close()
if readErr != nil {
return nil, readErr
}
if closeErr != nil {
return nil, closeErr
}
if len(content) > maxSkillFileBytes {
return nil, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB")
}
total += int64(len(content))
if total > maxSkillPackageBytes {
return nil, kernel.BadAuthRequest("技能包解压后不能超过 20MB")
}
contents[filePath] = content
}
return nil, nil, nil, nil, kernel.BadAuthRequest("技能文件不存在")
return contents, nil
}
func (s *Service) readSkillArchiveEntry(version *model.SkillVersion, filePath string) ([]byte, error) {
@@ -722,6 +935,20 @@ func normalizeSkillArchiveRoot(raw map[string][]byte, subdir string) (map[string
return files, nil
}
func contentHashAndSize(files map[string][]byte) (string, int64) {
hash := sha256.New()
var total int64
for _, filePath := range sortedSkillPaths(files) {
content := files[filePath]
total += int64(len(content))
hash.Write([]byte(filePath))
hash.Write([]byte{0})
hash.Write(content)
hash.Write([]byte{0})
}
return hex.EncodeToString(hash.Sum(nil)), total
}
func finalizeSkillArchive(files map[string][]byte, metadata skillPackageMetadata) (skillPackageArchive, error) {
if _, ok := files["SKILL.md"]; !ok {
return skillPackageArchive{}, kernel.BadAuthRequest("技能包入口必须是 SKILL.md")
@@ -735,17 +962,8 @@ func finalizeSkillArchive(files map[string][]byte, metadata skillPackageMetadata
if utf8.RuneCountInString(metadata.Name) > 80 || utf8.RuneCountInString(metadata.Description) > 500 {
return skillPackageArchive{}, kernel.BadAuthRequest("技能名称或简介超过长度限制")
}
hash := sha256.New()
var total int64
for _, filePath := range sortedSkillPaths(files) {
content := files[filePath]
total += int64(len(content))
hash.Write([]byte(filePath))
hash.Write([]byte{0})
hash.Write(content)
hash.Write([]byte{0})
}
return skillPackageArchive{Files: files, Metadata: metadata, ContentHash: hex.EncodeToString(hash.Sum(nil)), TotalBytes: total}, nil
hash, total := contentHashAndSize(files)
return skillPackageArchive{Files: files, Metadata: metadata, ContentHash: hash, TotalBytes: total}, nil
}
func encodeSkillArchive(files map[string][]byte) ([]byte, error) {

View File

@@ -140,33 +140,11 @@ export function AgentChatMessage({
const displayedText = useTypewriterText(item.text, item.role === "assistant" && isStreaming);
const color = item.role === "error" ? "#ef4444" : theme.node.text;
if (item.reasoning) {
return (
<div className="agent-reasoning" style={{ "--agent-reasoning-accent": theme.accent.primary } as CSSProperties}>
<details className={`agent-reasoning-card${item.streaming ? " is-streaming" : ""}`} open={item.streaming || undefined}>
<summary className="agent-reasoning-summary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20">
<span className="agent-reasoning-icon" aria-hidden="true">
<Sparkles className="size-3.5" />
</span>
<span className="agent-reasoning-copy">
<span className="agent-reasoning-title">{item.streaming ? "模型正在思考" : "模型思考"}</span>
<span className="agent-reasoning-subtitle">{item.streaming ? "整理目标与下一步" : "推理摘要"}</span>
</span>
<span className={`agent-reasoning-status${item.streaming ? " is-live" : ""}`}>
{item.streaming ? <span className="agent-reasoning-status-dot" aria-hidden="true" /> : null}
{item.streaming ? "实时" : "查看"}
</span>
<ChevronDown className="agent-reasoning-chevron" aria-hidden="true" />
</summary>
<div className="agent-reasoning-content" data-canvas-wheel-scroll>
<div className="agent-reasoning-text">{item.text || (item.streaming ? "正在整理思路…" : "暂无可展示的推理摘要")}</div>
</div>
</details>
</div>
);
return <AgentReasoningFeed items={[item]} theme={theme} />;
}
if (isSystem) {
return (
<div className="flex items-start gap-3 text-xs">
<div className="agent-status-message flex items-start gap-2 text-xs">
<AgentTimelineMarker theme={theme} tone="muted" icon={<Sparkles className="size-3" />} />
<div className="min-w-0 flex-1 py-0.5 leading-5" style={{ color: theme.node.muted }}>
{item.text}
@@ -185,7 +163,7 @@ export function AgentChatMessage({
}
if (item.role === "error") {
return (
<div className="flex items-start gap-3">
<div className="agent-status-message flex items-start gap-2">
<AgentTimelineMarker theme={theme} tone="error" icon={<CircleAlert className="size-3.5" />} />
<div className="min-w-0 flex-1 py-0.5 text-[13px] leading-5">
<div className="font-medium" style={{ color }}>
@@ -233,6 +211,42 @@ export function AgentChatMessage({
);
}
/**
* 推理是辅助信息,不应与正文和工具输出争夺主视觉。一个事件流里的连续摘要
* 合并成一个入口,默认收起;需要排查时再展开查看完整内容。
*/
export function AgentReasoningFeed({
items,
theme,
}: {
items: CloudAgentChatMessage[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
}) {
const streaming = items.some((item) => item.streaming);
const text = items.map((item) => item.text.trim()).filter(Boolean).join("\n\n");
const countLabel = items.length > 1 ? `${items.length} 段 · ` : "";
return (
<div className="agent-reasoning" style={{ "--agent-reasoning-accent": theme.accent.primary } as CSSProperties}>
<details className={`agent-reasoning-card${streaming ? " is-streaming" : ""}`}>
<summary className="agent-reasoning-summary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/20">
<span className="agent-reasoning-copy">
<span className="agent-reasoning-title">{streaming ? "模型正在思考" : "模型思考"}</span>
<span className="agent-reasoning-subtitle">{streaming ? "实时整理 · 点击查看" : `${countLabel}点击查看`}</span>
</span>
<span className={`agent-reasoning-status${streaming ? " is-live" : ""}`}>
{streaming ? <span className="agent-reasoning-status-dot" aria-hidden="true" /> : null}
{streaming ? "实时" : "查看"}
</span>
<ChevronDown className="agent-reasoning-chevron" aria-hidden="true" />
</summary>
<div className="agent-reasoning-content" data-canvas-wheel-scroll>
<div className="agent-reasoning-text">{text || (streaming ? "正在整理思路…" : "暂无可展示的推理摘要")}</div>
</div>
</details>
</div>
);
}
/**
* Agent SSE events contain text chunks. Keep the full text in the message
* state, but reveal one code point at a time so a chunk never appears as a
@@ -348,7 +362,7 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
const impact = agentImpactFromDetail(detail);
const friendlySummary = friendlyAgentToolSummary(agentToolName("", detail), summary, detail, true);
return (
<div className="flex items-start gap-3">
<div className="agent-status-message flex items-start gap-2">
<AgentTimelineMarker theme={theme} tone="approval" icon={<CircleAlert className="size-3.5" />} />
<div className="agent-pending-tool min-w-0 flex-1 rounded-lg border py-2 pl-3 pr-3" style={{ borderColor: "rgba(249,115,22,.22)", background: "rgba(249,115,22,.05)", color: theme.node.text }}>
<div className="flex items-start gap-3">
@@ -462,7 +476,7 @@ export function AgentToolCard({
return (
<details data-agent-tool-retry className="min-w-0 flex-1 text-xs leading-5" style={{ color: theme.node.muted }}>
<summary className="cursor-pointer rounded-sm focus-visible:outline focus-visible:outline-2" style={{ outlineColor: theme.node.muted }}>
{label} · {retry.attempt}/{retry.maxAttempts} 次尝试未通过
{retry.status === "recovered" ? `${label} · 已恢复` : `${label} · ${retry.attempt}/${retry.maxAttempts} 次尝试未通过`}
</summary>
<ol className="mt-2 space-y-1 pl-4" aria-label="自动纠正详情">
{(attempts as AgentToolRetryAttempt[]).map((attempt, index) => (
@@ -567,7 +581,9 @@ export function AgentOperationFeed({
const category = agentOperationCategory(latest);
const categoryLabel = agentToolCategoryLabel(agentToolName(latest.title || "工具执行", latest.detail), category);
const label = agentOperationSegmentLabel(items);
const failed = items.some((item) => agentOperationFailed(item));
// 只看最新一步的状态。前面的参数错误如果已被自动纠正,不应让整段
// 操作流继续保持红色并默认展开,否则用户会误以为最终动作仍然失败。
const failed = agentOperationFailed(latest);
const expanded = userExpanded ?? failed;
const shimmering = live && !failed;
return (
@@ -581,8 +597,7 @@ export function AgentOperationFeed({
aria-label={`${expanded ? "收起" : "展开"} ${items.length} 步${categoryLabel}记录,最新一步:${label}`}
onClick={() => setUserExpanded(!expanded)}
>
<span className="agent-tool-category">
{agentToolCategoryIcon(category)}
<span className="agent-operation-kind">
<span>{categoryLabel}</span>
</span>
{/* 换步时旧文案高模糊淡出、新文案从下方上浮(先快后慢的非线性曲线)。 */}
@@ -1237,7 +1252,7 @@ function AgentTimelineMarker({ theme, tone, icon }: { theme: (typeof canvasTheme
const color = tone === "error" ? "#ef4444" : tone === "approval" ? "#f97316" : tone === "agent" ? theme.accent.primary : theme.node.muted;
return (
<span className="agent-timeline-marker relative" aria-hidden="true">
<span className="relative grid size-6 place-items-center rounded-full" style={{ background: tone === "agent" ? theme.accent.primarySoft : theme.node.fill, color }}>
<span className="relative grid size-5 place-items-center rounded-full" style={{ background: tone === "agent" ? theme.accent.primarySoft : theme.node.fill, color }}>
{icon}
</span>
</span>

View File

@@ -13,7 +13,6 @@ import { nanoid } from "nanoid";
import { ModelPicker } from "@/components/model-picker";
import { FluidOrb } from "@/components/ui/fluid-orb";
import { cn } from "@/lib/utils";
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { agentErrorPresentation, agentSubmissionErrorTitle } from "@/lib/canvas/agent-error-presentation";
@@ -33,7 +32,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
import { AGENT_SCENE_DEFS, AgentChatComposer, AgentChatMessage, AgentOperationFeed, AgentPlanBar, AgentQuestionBar, AgentSceneCapsules, AgentWorkingMessage, type AgentSceneBucket, type CloudAgentChatMessage, type CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui";
import { AGENT_SCENE_DEFS, AgentChatComposer, AgentChatMessage, AgentOperationFeed, AgentPlanBar, AgentQuestionBar, AgentReasoningFeed, AgentSceneCapsules, AgentWorkingMessage, type AgentSceneBucket, type CloudAgentChatMessage, type CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui";
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual, type AgentContextKey } from "./canvas-cloud-agent-settings";
import { useAgentPanelLayout } from "./use-agent-panel-layout";
@@ -128,8 +127,6 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
const preferred = config.textModel || config.model || "";
return textModels.includes(preferred) ? preferred : (textModels[0] || "");
}, [config]);
const reasoningSupported = Boolean(modelCapabilityConfigFor(config, selectedModel).text?.thinking);
useEffect(() => { if (!reasoningSupported && reasoningMode !== "off") setReasoningMode("off"); }, [reasoningSupported, reasoningMode]);
const installedSkills = useMemo(() => skills.filter((skill) => skill.isAdded), [skills]);
const enabledSkills = useMemo(() => installedSkills.filter((skill) => selectedSkillIds.includes(skill.skillId)), [installedSkills, selectedSkillIds]);
const installedSkillIds = useMemo(() => new Set(installedSkills.map((skill) => skill.skillId)), [installedSkills]);
@@ -541,7 +538,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
const requestConfig = resolveModelRequestConfig(agentConfig, selectedModel);
const logicalModelId = logicalModelIDForConfig(agentConfig);
const input = {
canvasId, prompt: value, reasoningMode: reasoningSupported ? reasoningMode : "off", profileRevision: profileView.revision,
canvasId, prompt: value, reasoningMode, profileRevision: profileView.revision,
model: modelOptionName(selectedModel) || undefined,
...(logicalModelId ? { logicalModelId } : requestConfig.channelId ? { channelId: requestConfig.channelId, channelModelKey: modelOptionName(selectedModel) || undefined } : {}),
skillIds: [...new Set([...selectedSkillIds, ...resolveSkillMentions(value, installedSkills).map((skill) => skill.skillId)])],
@@ -910,9 +907,6 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
submitAccessory={<AgentContextRing view={presentAgentContextUsage(contextUsage)} />}
left={
<ComposerControls
reasoningMode={reasoningSupported ? reasoningMode : "off"}
reasoningSupported={reasoningSupported}
onReasoningModeChange={(value) => { if (reasoningSupported) setReasoningMode(value); }}
config={config}
selectedModel={selectedModel}
permissionMode={permissionMode}
@@ -1279,6 +1273,8 @@ function AgentConversation({
segment.kind === "operations" ? (
// 只有"对话末尾那一段 + 还在跑"才流光:历史段落留在静态态,任务完成即停。
<AgentOperationFeed key={segment.key} items={segment.items} theme={theme} references={references} onFocusNode={onFocusNode} live={busy && index === segments.length - 1} />
) : segment.kind === "reasoning" ? (
<AgentReasoningFeed key={segment.key} items={segment.items} theme={theme} />
) : (
<AgentChatMessage key={segment.key} item={segment.item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && segment.item.streaming === true && segment.item === lastMessage} />
),
@@ -1293,9 +1289,6 @@ function AgentConversation({
}
function ComposerControls({
reasoningMode,
reasoningSupported,
onReasoningModeChange,
config,
selectedModel,
permissionMode,
@@ -1306,9 +1299,6 @@ function ComposerControls({
onSkillsOpenChange,
selectedSkillCount,
}: {
reasoningMode: AgentReasoningMode;
reasoningSupported: boolean;
onReasoningModeChange: (value: AgentReasoningMode) => void;
config: ReturnType<typeof useEffectiveConfig>;
selectedModel: string;
permissionMode: AgentPermissionMode;
@@ -1336,11 +1326,6 @@ function ComposerControls({
showOptionPrices
placeholder="选择文本模型"
/>
{reasoningSupported ? <Dropdown trigger={["click"]} placement="topLeft" menu={{ items: reasoningMenuItems(reasoningMode, onReasoningModeChange) }}>
<button type="button" aria-label="选择 Agent 推理模式" title="推理模式:只用于规划和工具选择" className="flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25" style={{ color: reasoningMode === "off" ? theme.node.muted : theme.accent.primary, background: reasoningMode === "off" ? "transparent" : theme.node.fill }}>
<Sparkles className="size-3.5" />{reasoningModeLabel(reasoningMode)}
</button>
</Dropdown> : null}
<Dropdown trigger={["click"]} placement="topLeft" menu={{ items: agentPermissionMenuItems(permissionMode, onPermissionChange) }}>
<button
type="button"
@@ -1369,19 +1354,6 @@ function ComposerControls({
);
}
const reasoningLabels: Record<AgentReasoningMode, string> = { off: "直达", auto: "自动推理", deep: "深入推理" };
function reasoningModeLabel(mode: AgentReasoningMode) { return reasoningLabels[mode]; }
function reasoningMenuItems(mode: AgentReasoningMode, onChange: (value: AgentReasoningMode) => void) {
return (Object.keys(reasoningLabels) as AgentReasoningMode[]).map((value) => ({
key: value,
label: reasoningLabels[value],
icon: value === mode ? <Check className="size-3.5" /> : undefined,
onClick: () => onChange(value),
}));
}
function ApprovalCard({ approval, theme, submitting, onFocusNode, onReasonChange, onApprove, onReject }: { approval: ApprovalState; theme: CanvasTheme; submitting: boolean; onFocusNode?: (nodeId: string) => void; onReasonChange: (value: string) => void; onApprove: (settings?: AgentMediaSettings) => void; onReject: () => void }) {
const [showReason, setShowReason] = useState(Boolean(approval.reason));
const [mediaSettings, setMediaSettings] = useState<AgentMediaSettings>();

View File

@@ -388,25 +388,22 @@
scroll-padding-block: var(--space-5);
}
/*
* 过程内容的公共轴:正文的左边缘就是这条线,过程标记(星标、工具左轨、时间线标记)
* 一律挂在轴上居中。轴由星标几何推导,不是拍出来的数:星标中心 = 思考缩进 + 摘要内边距
* + 图标半宽;工具左轨用 -0.5px 让 1px 的线居中在轴上。改任一项,三处会一起跟着走。
*/
.agent-conversation-messages {
--agent-axis: 22px;
--agent-thought-icon: 16px;
--agent-thought-gap: 9px;
--agent-summary-inset: 4px;
--agent-timeline-marker: 24px;
--agent-activity-inset: 8px;
padding-left: var(--agent-axis);
min-width: 0;
}
.agent-conversation-messages > * + * {
margin-top: var(--space-4);
}
/* 状态行的图标只是语义标记,不应把正文向右推成另一条内容轴。 */
.agent-status-message {
min-width: 0;
}
.agent-message-user {
border-radius: var(--agent-radius-surface) var(--agent-radius-surface) var(--space-2) var(--agent-radius-surface);
background: var(--agent-surface-hover, color-mix(in srgb, var(--foreground) 7%, transparent));
@@ -684,11 +681,6 @@
.agent-context-panel-foot { display: grid; }
.agent-welcome-actions button { padding-inline: var(--space-2); }
/* 窄面板把公共轴收细,星标与左轨仍压在同一条线上,不吃正文宽度。 */
.agent-conversation-messages {
--agent-axis: 16px;
--agent-activity-inset: var(--space-1);
}
}
@media (max-width: 639px) {
@@ -803,7 +795,7 @@
border: 0;
border-radius: 8px;
background: transparent;
padding: 5px 2px;
padding: 5px 0;
transition: transform 160ms ease;
}
@@ -843,7 +835,7 @@
border-color: transparent;
border-radius: 9px;
background: transparent;
padding: 3px 2px;
padding: 3px 0;
}
.agent-tool-row--read {
@@ -871,7 +863,7 @@
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: 7px;
gap: 6px;
line-height: 18px;
}
@@ -891,6 +883,14 @@
line-height: 15px;
}
.agent-tool-label {
flex: 0 0 auto;
font-size: 10px;
font-weight: 600;
line-height: 15px;
white-space: nowrap;
}
.agent-tool-summary {
min-width: 0;
flex: 1 1 180px;
@@ -968,11 +968,8 @@
*/
.agent-operation-feed {
--agent-tool-accent: color-mix(in srgb, var(--foreground) 62%, transparent);
/* 层级第二档:左轨与正文左边缘同轴(见 .agent-conversation-messages 的公共轴),
轨道把"一连串动作"拢成一条活动流,内容落在轨的右侧。 */
margin-left: -0.5px;
border-left: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
padding-left: var(--agent-activity-inset, 8px);
/* 操作流与正文共用同一左对齐轴。不要用竖线把每条工具记录切成
独立的日志栏:用户首先要看到的是 Agent 的输出和当前动作。 */
min-width: 0;
}
@@ -986,11 +983,11 @@
min-width: 0;
max-width: 100%;
align-items: center;
gap: var(--space-2);
gap: 6px;
border: 0;
border-radius: 9px;
background: transparent;
padding: 4px var(--space-2) 4px var(--space-1);
padding: 5px 0;
color: color-mix(in srgb, var(--agent-ink, currentColor) 72%, transparent);
font-size: var(--fs-caption);
line-height: 18px;
@@ -1001,6 +998,20 @@
color 140ms ease;
}
/* The folded receipt is a single sentence, not another pill-shaped card. */
.agent-operation-list .agent-tool-category {
background: transparent;
padding-inline: 0;
}
.agent-operation-kind {
flex: 0 0 auto;
color: color-mix(in srgb, var(--agent-tool-accent) 86%, var(--foreground));
font-size: var(--fs-caption);
font-weight: 600;
white-space: nowrap;
}
.agent-operation-toggle:hover {
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 4%, transparent));
color: var(--agent-ink, var(--foreground));
@@ -1052,9 +1063,7 @@
.agent-operation-count {
flex: 0 0 auto;
border-radius: 999px;
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 6%, transparent));
padding: 1px var(--space-2);
padding: 0 2px;
color: color-mix(in srgb, var(--agent-ink, currentColor) 62%, transparent);
font-size: var(--fs-tiny);
line-height: 16px;
@@ -1320,37 +1329,21 @@
* 比工具日志更安静、比正文更有层次。使用当前画布主题的 accent,避免
* 固定紫色的 AI 默认样式,同时把实时状态、摘要内容和展开动作分开。
*/
/*
* 信息层级:正文 / 工具调用 / 模型思考 三段只靠「位置 + 字号 + 对比度」分层,
* 不靠圆底图标或品牌 glyph。
*
* 正文(结论) 从公共轴开始、14px、满对比 —— 主角
* 工具调用 左轨压在公共轴上(活动流),内容在轨右侧,12px、次对比
* 模型思考 星标居中在公共轴上,12px/11px、最淡 —— 过程里的旁白
*/
/* 信息层级:正文 / 工具调用 / 模型思考三段只靠位置、字号和对比度分层。 */
.agent-reasoning {
min-width: 0;
/* 星标挂进旁注沟槽:中心落在公共轴上(正文左边缘)。 */
margin-left: calc(-1 * (var(--agent-thought-icon, 16px) / 2 + var(--agent-summary-inset, 4px)));
color: color-mix(in srgb, var(--foreground) 58%, transparent);
}
/*
* 时间线标记列(系统行 / 错误行 / 审批卡 / 上下文整理):宽度与挂点都走公共轴 token。
*/
/* 系统行、错误行和审批卡保留一个窄图标列,但不再向左挂出一条公共竖轨。 */
.agent-timeline-marker {
display: flex;
width: var(--agent-timeline-marker, 24px);
flex: 0 0 var(--agent-timeline-marker, 24px);
width: 20px;
flex: 0 0 20px;
align-self: stretch;
justify-content: center;
}
/* 只有对话流里的标记向左挂半格:中心与星标、工具左轨共用同一条公共轴。 */
.agent-conversation-messages .agent-timeline-marker {
margin-left: calc(-1 * (var(--agent-timeline-marker, 24px) / 2));
}
.agent-reasoning-card {
min-width: 0;
overflow: hidden;
@@ -1369,11 +1362,10 @@
display: flex;
min-width: 0;
align-items: center;
gap: var(--agent-thought-gap, 9px);
gap: 0;
cursor: pointer;
list-style: none;
/* 内边距参与公共轴推导:改它,左轨会跟着一起挪(见 .agent-conversation-messages)。 */
padding: 6px var(--agent-summary-inset, 4px);
padding: 6px 0;
color: color-mix(in srgb, var(--foreground) 66%, transparent);
user-select: none;
}
@@ -1478,12 +1470,11 @@
.agent-reasoning-content {
position: relative;
display: flex;
max-height: min(232px, 30vh);
max-height: min(180px, 24vh);
gap: 0;
overflow: auto;
border-top: 0;
/* 与摘要的标题左对齐:摘要内边距 + 星标列 + 摘要 gap。 */
padding: 4px 10px 10px calc(var(--agent-summary-inset, 4px) + var(--agent-thought-icon, 16px) + var(--agent-thought-gap, 9px));
padding: 4px 0 10px;
scrollbar-gutter: stable;
}

View File

@@ -15,12 +15,16 @@ export type AgentFeedRecord = {
role: string;
title?: string;
text: string;
reasoning?: boolean;
detail?: unknown;
planItems?: readonly unknown[];
question?: unknown;
};
export type AgentFeedSegment<T> = { kind: "operations"; key: string; items: T[] } | { kind: "message"; key: string; item: T };
export type AgentFeedSegment<T> =
| { kind: "operations"; key: string; items: T[] }
| { kind: "reasoning"; key: string; items: T[] }
| { kind: "message"; key: string; item: T };
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
@@ -41,6 +45,11 @@ export function isAgentOperationRecord(item: AgentFeedRecord): boolean {
return item.role === "tool" && !isAgentCarrierRecord(item) && record(item.detail).status !== "pending";
}
/** 连续推理事件共用一条可展开记录,避免每个 SSE chunk/摘要都变成一行。 */
export function isAgentReasoningRecord(item: AgentFeedRecord): boolean {
return item.role === "assistant" && item.reasoning === true;
}
/** 折叠行上报的那一步:最新一条操作记录的友好摘要(等待执行时用"准备…"口径)。 */
export function agentOperationLabel(item: AgentFeedRecord): string {
const name = agentToolName(item.title || "工具执行", item.detail);
@@ -103,6 +112,12 @@ export function buildAgentFeedSegments<T extends AgentFeedRecord>(messages: read
const segments: AgentFeedSegment<T>[] = [];
for (const item of messages) {
if (isAgentCarrierRecord(item)) continue;
if (isAgentReasoningRecord(item)) {
const last = segments[segments.length - 1];
if (last?.kind === "reasoning") last.items.push(item);
else segments.push({ kind: "reasoning", key: item.id, items: [item] });
continue;
}
if (isAgentOperationRecord(item)) {
const last = segments[segments.length - 1];
if (last?.kind === "operations") last.items.push(item);

View File

@@ -135,7 +135,9 @@ function field(value: unknown, key: string): unknown {
export function agentToolStatus(title: string, text: string, detail?: unknown): ToolStatus {
const event = field(detail, "eventType");
if (event === "tool_failed" && agentToolRetry(detail)?.status === "retrying") return "retrying";
const retry = agentToolRetry(detail);
if (retry?.status === "retrying") return "retrying";
if (retry?.status === "recovered") return "completed";
if (event === "tool_completed" || event === "generation_task_created" || event === "canvas_updated") return "completed";
if (event === "tool_failed") return "failed";
const raw = `${title} ${text} ${field(detail, "error") || ""}`;
@@ -149,8 +151,14 @@ export function agentToolStatus(title: string, text: string, detail?: unknown):
export function friendlyAgentToolSummary(toolName: string, text: string, detail?: unknown, pending = false) {
const status = agentToolStatus(toolName, text, detail);
const failed = status === "failed" || status === "rejected";
if (toolName === "generate_media" && failed && field(field(detail, "result"), "phase") === "admission") return "生成请求未提交";
if (toolName === "generate_media" && failed && field(field(detail, "result"), "status") === "succeeded") return "生成成功,画布回写未完成";
const mediaTool = toolName === "generate_media" || toolName === "image_layer_split";
const result = record(field(detail, "result"));
const phase = result.phase;
if (mediaTool && failed && phase === "admission") return "参数未通过校验,未提交生成任务";
if (mediaTool && failed && phase === "completion" && result.taskSubmitted === true) {
if (result.writebackReason || result.status === "succeeded") return "生成成功,画布回写未完成";
return "任务已提交,但生成结果未完成";
}
if (toolName === "skills_load") {
const count = /(?:加载|启用)\s*(\d+)\s*个/u.exec(text)?.[1];
return failed ? "Skills 技能加载失败" : count ? `已加载 ${count} 个 Skills 技能` : "已加载 Skills 技能";

View File

@@ -67,7 +67,7 @@ export function uninsertedCanvasAssetHandoffPayloads(nodes: Iterable<{ metadata?
return payloads.filter((payload) => !payload.assetId || !insertedAssetIds.has(payload.assetId));
}
export function creationResultAssetIds(assets: Asset[], input: { messageId: string; taskIds: string[]; resultUrls: string[] }) {
export function creationResultAssetIds(assets: Asset[], input: { messageId: string; taskIds: string[]; resultUrls: string[]; resultStorageKeys?: string[] }) {
const taskOrder = new Map(input.taskIds.map((taskId, index) => [taskId, index]));
const candidates = assets
.filter((asset) => asset.kind === "image" || asset.kind === "video")
@@ -82,13 +82,24 @@ export function creationResultAssetIds(assets: Asset[], input: { messageId: stri
return leftResult - rightResult;
});
const unused = new Set(candidates.map((asset) => asset.id));
const allowOrderedFallback = candidates.length === input.resultUrls.length;
return input.resultUrls.flatMap((url) => {
const exact = candidates.find((asset) => unused.has(asset.id) && (asset.coverUrl === url || (asset.kind === "image" ? asset.data.dataUrl === url : asset.data.url === url)));
const stableKeys = input.resultStorageKeys || [];
const allowOrderedFallback = candidates.length === Math.max(input.resultUrls.length, stableKeys.length);
return Array.from({ length: Math.max(input.resultUrls.length, stableKeys.length) }, (_, index) => {
const storageKey = stableKeys[index];
const url = input.resultUrls[index] || "";
const exact = candidates.find((asset) => unused.has(asset.id) && Boolean(storageKey) && asset.data.storageKey === storageKey)
|| candidates.find((asset) => unused.has(asset.id) && (asset.coverUrl === url || (asset.kind === "image" ? asset.data.dataUrl === url : asset.data.url === url)));
const fallback = exact || (allowOrderedFallback ? candidates.find((asset) => unused.has(asset.id)) : undefined);
if (!fallback) return [];
if (!fallback) return undefined;
unused.delete(fallback.id);
return [fallback.id];
return fallback.id;
}).filter((id): id is string => Boolean(id));
}
export function creationResultStorageKeys(assets: Asset[], input: { messageId: string; taskIds: string[]; resultUrls: string[]; resultStorageKeys?: string[] }) {
return creationResultAssetIds(assets, input).flatMap((assetId) => {
const asset = assets.find((candidate) => candidate.id === assetId);
return asset && (asset.kind === "image" || asset.kind === "video") && asset.data.storageKey ? [asset.data.storageKey] : [];
});
}

View File

@@ -6,7 +6,7 @@ import { creationAttachmentKind, type CreationAttachment } from "./creation-asse
import type { CreationConversation, CreationMessage, CreationShotRailEntry } from "./creation-types";
type CreationRuntime = typeof import("./creation-runtime");
type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationError?: string };
type PersistedCreationTask = GenerationTask & { creationResultUrls?: string[]; creationResultStorageKeys?: string[]; creationError?: string };
export function newConversation(): CreationConversation {
return { id: createClientId(), title: "新创作", updatedAt: new Date().toISOString(), messages: [] };
@@ -76,7 +76,8 @@ export async function materializeCreationTaskResults(runtime: CreationRuntime, t
try {
const materialized = await runtime.runGenerationConsumer(signal, (managedSignal: AbortSignal) => runtime.materializeGenerationTaskAssets(task, managedSignal));
const creationResultUrls = runtime.generationTaskMaterializedUrls(materialized);
return creationResultUrls.length ? { ...materialized, creationResultUrls } : materialized;
const creationResultStorageKeys = runtime.generationTaskMaterializedStorageKeys(materialized);
return creationResultUrls.length || creationResultStorageKeys.length ? { ...materialized, ...(creationResultUrls.length ? { creationResultUrls } : {}), ...(creationResultStorageKeys.length ? { creationResultStorageKeys } : {}) } : materialized;
} catch (error) {
return { ...task, creationError: error instanceof Error ? error.message : "生成结果资源化失败" };
}
@@ -105,16 +106,18 @@ export function reconcileCreationTaskMessages(runtime: CreationRuntime, conversa
const expectedTaskCount = Math.max(0, ...matches.map((task) => task.clientContext?.batchCount || 0));
if (!matches.length || (expectedTaskCount > 0 && matches.length < expectedTaskCount) || matches.some((task) => task.status === "queued" || task.status === "running")) return message;
const resultUrls = Array.from(new Set(matches.filter((task) => task.status === "succeeded").flatMap(creationTaskResultUrls)));
const succeeded = matches.filter((task) => task.status === "succeeded");
const resultUrls = Array.from(new Set(succeeded.flatMap(creationTaskResultUrls)));
const resultStorageKeys = Array.from(new Set(succeeded.flatMap(creationTaskResultStorageKeys)));
const failedCount = matches.filter((task) => task.status !== "succeeded" || Boolean(task.creationError)).length;
const nextTaskIds = Array.from(new Set([...(message.taskIds || []), ...matches.map((task) => task.id)]));
completedAt = matches.reduce((latest, task) => conversationTimestamp(task.updatedAt) > conversationTimestamp(latest) ? task.updatedAt : latest, completedAt);
conversationChanged = true;
changed = true;
if (resultUrls.length) {
const content = message.mode === "video" ? "视频已生成" : failedCount ? `${resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成";
return { ...message, status: "done" as const, content, resultUrls, error: undefined, taskIds: nextTaskIds };
if (resultUrls.length || resultStorageKeys.length) {
const content = message.mode === "video" ? "视频已生成" : failedCount ? `${resultStorageKeys.length || resultUrls.length} 张图片已生成,${failedCount} 张失败` : "图片已生成";
return { ...message, status: "done" as const, content, ...(resultUrls.length ? { resultUrls } : {}), ...(resultStorageKeys.length ? { resultStorageKeys } : {}), error: undefined, taskIds: nextTaskIds };
}
if (matches.every((task) => task.status === "cancelled")) {
return { ...message, status: "cancelled" as const, content: "已停止", error: undefined, taskIds: nextTaskIds };
@@ -132,6 +135,11 @@ function creationTaskResultUrls(task: PersistedCreationTask) {
return [];
}
function creationTaskResultStorageKeys(task: PersistedCreationTask) {
if (task.creationResultStorageKeys?.length) return task.creationResultStorageKeys;
return [];
}
export function conversationTimestamp(value: string) {
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;

View File

@@ -3,7 +3,7 @@ export { isGenerationTaskCancelled, runBackendGenerationTask, runBackendGenerati
export { subscribeGenerationTasks } from "@/services/api/task-center";
export { uploadMediaFile } from "@/services/file-storage";
export { uploadImage } from "@/services/image-storage";
export { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
export { consumeGenerationTaskMessage, generationTaskMaterializedStorageKeys, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync";
export { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe";
export { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
export { recoverCreationTextTask } from "@/services/creation-text-task-recovery";

View File

@@ -18,7 +18,10 @@ export type CreationMessage = {
createdAt: string;
status?: CreationStatus;
model?: string;
/** 当前会话内的临时访问地址;长期身份使用 resultStorageKeys。 */
resultUrls?: string[];
/** 生成结果对应的稳定素材定位符,不随 OSS 签名 URL 过期。 */
resultStorageKeys?: string[];
error?: string;
generationErrorCode?: string;
generationOperation?: string;

View File

@@ -14,7 +14,7 @@ import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
import { WorkingDots, WorkingGlow } from "@/components/ai/working-indicator";
import { MessageReasoning } from "@/components/ai/message-reasoning";
import { creationResultAssetIds } from "@/lib/canvas/canvas-asset-handoff";
import { creationResultAssetIds, creationResultStorageKeys } from "@/lib/canvas/canvas-asset-handoff";
import { generationErrorMessage } from "@/lib/generation-error";
import { formatVideoResolutionLabel as videoResolutionLabel } from "@/lib/video-generation-options";
import { useAssetStore } from "@/stores/use-asset-store";
@@ -36,7 +36,9 @@ import { mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model
import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing";
import type { Skill } from "@/services/api/skills";
import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
import { resolveResourceUrl } from "@/services/api/resources";
import { getResourceAccess, resolveResourceAccessURL, resolveResourceUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { resolveMediaUrl } from "@/services/file-storage";
import { resolveImageUrl } from "@/services/image-storage";
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { useUserStore } from "@/stores/use-user-store";
@@ -253,16 +255,76 @@ function MediaResult({ item, onRetryFailure, onCreateVariant, onContinueCanvas,
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
const assets = useAssetStore((state) => state.assets);
const resultUrls = item.resultUrls || [];
const resultAssetIds = resultUrls.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls }) : [];
const canContinueWithResults = resultUrls.length > 0 && resultAssetIds.length === resultUrls.length;
const storedResultUrls = item.resultUrls || [];
const resultStorageKeys = item.resultStorageKeys?.length
? item.resultStorageKeys
: creationResultStorageKeys(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: storedResultUrls });
const [resolvedResultUrls, setResolvedResultUrls] = useState(storedResultUrls);
useEffect(() => {
let active = true;
const timers: ReturnType<typeof setTimeout>[] = [];
const updateResultUrl = (index: number, url: string) => {
if (!active) return;
setResolvedResultUrls((current) => {
const next = [...current];
next[index] = url;
return next;
});
};
const refreshRemote = async (index: number, storageKey: string): Promise<void> => {
try {
const access = await getResourceAccess(storageKey, "display");
if (!active) return;
updateResultUrl(index, resolveResourceAccessURL(access.url));
const refreshAt = access.refreshAt ? new Date(access.refreshAt).getTime() : Number.NaN;
const expiresAt = access.expiresAt ? new Date(access.expiresAt).getTime() : Number.NaN;
const nextRefreshAt = Number.isFinite(refreshAt) ? refreshAt : expiresAt;
const delay = Number.isFinite(nextRefreshAt) ? Math.max(10_000, nextRefreshAt - Date.now()) : 4 * 60_000;
timers.push(setTimeout(() => void refreshRemote(index, storageKey), delay));
} catch {
if (active) timers.push(setTimeout(() => void refreshRemote(index, storageKey), 60_000));
}
};
if (!resultStorageKeys.length) {
setResolvedResultUrls(storedResultUrls);
return () => {
active = false;
};
}
// 历史记录只把 URL 当作短期 hint;真正恢复依赖稳定 storageKey,展示 URL 由访问缓存按需续签。
setResolvedResultUrls(storedResultUrls);
const resolver = item.mode === "video" ? resolveMediaUrl : resolveImageUrl;
void Promise.all(
resultStorageKeys.map(async (storageKey, index) => {
if (resourceIdFromStorageKey(storageKey)) {
await refreshRemote(index, storageKey);
return;
}
const url = await resolver(storageKey, storedResultUrls[index] || "");
updateResultUrl(index, url);
}),
).catch(() => {
// 保留当前页面已有的 URL hint;若已过期,页面会走明确的媒体空态而不是回退到平台正文代理。
});
return () => {
active = false;
timers.forEach((timer) => clearTimeout(timer));
};
}, [item.id, item.mode, resultStorageKeys.join("|"), storedResultUrls.join("|")]);
const alignedResultUrls = resultStorageKeys.length ? resolvedResultUrls : storedResultUrls;
const displayResultUrls = alignedResultUrls.filter(Boolean);
const resultAssetIds = alignedResultUrls.length || resultStorageKeys.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: alignedResultUrls, resultStorageKeys }) : [];
const expectedResultCount = resultStorageKeys.length || alignedResultUrls.length;
const canContinueWithResults = expectedResultCount > 0 && resultAssetIds.length === expectedResultCount;
if (item.status === "pending") return <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} />;
if ((item.status === "error" || item.status === "cancelled") && !resultUrls.length) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
if (!resultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
if ((item.status === "error" || item.status === "cancelled") && !expectedResultCount) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
if (!expectedResultCount || !displayResultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
const isVideo = item.mode === "video";
return <div className="creation-media-result">
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(resultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={resultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{resultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${resultUrls.length} 张图片`}</span><Button type="link" size="small" loading={openingCanvas} disabled={!canContinueWithResults} title={canContinueWithResults ? undefined : "素材保存完成后才能转入画布"} onClick={() => onContinueCanvas(resultAssetIds)}>添加到画布</Button>{resultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{resultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(displayResultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={displayResultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{displayResultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${displayResultUrls.length} 张图片`}</span><Button type="link" size="small" loading={openingCanvas} disabled={!canContinueWithResults} title={canContinueWithResults ? undefined : "素材保存完成后才能转入画布"} onClick={() => onContinueCanvas(resultAssetIds)}>添加到画布</Button>{displayResultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{displayResultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</div>;
}

View File

@@ -295,15 +295,15 @@ export default function CreatePage() {
const persistedTasks = await materializeCreationTaskResults(runtime, contextual, observationController.signal);
if (cancelled) return;
taskSyncWarningRef.current = false;
const attachable = persistedTasks.filter((task) => task.status === "succeeded" && Boolean(task.clientContext?.messageId) && Boolean(task.creationResultUrls?.length));
const attachable = persistedTasks.filter((task) => task.status === "succeeded" && Boolean(task.clientContext?.messageId) && Boolean(task.creationResultUrls?.length || task.creationResultStorageKeys?.length));
for (const task of attachable) {
try {
await runtime.consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls }) => {
await runtime.consumeGenerationTaskMessage(task, task.clientContext!.messageId!, async ({ effectKey, resultUrls, resultStorageKeys }) => {
if (cancelled) return;
await updateConversationMessage(task.clientContext!.conversationId!, task.clientContext!.messageId!, (item) =>
runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value,
runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, ...(resultUrls.length ? { resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) } : {}), ...(resultStorageKeys.length ? { resultStorageKeys: Array.from(new Set([...(current.resultStorageKeys || []), ...resultStorageKeys])) } : {}) })).value,
);
}, { signal: observationController.signal, materialize: async () => task, materializedUrls: runtime.generationTaskMaterializedUrls });
}, { signal: observationController.signal, materialize: async () => task, materializedUrls: runtime.generationTaskMaterializedUrls, materializedStorageKeys: runtime.generationTaskMaterializedStorageKeys });
} catch (error) {
if (cancelled || observationController.signal.aborted) return;
console.warn("创作任务结果挂载失败,将使用已物化结果收敛消息状态", error);
@@ -719,8 +719,8 @@ export default function CreatePage() {
const storedImages = await Promise.allSettled(generatedImages.map(async ({ image, taskId, batchIndex }) => {
if (!taskId) throw new Error("生成任务缺少稳定任务标识");
const task = completedCreationGenerationTask(runtime, { taskId, task: boundTasks.get(taskId), mode: "image", prompt: expandedPrompt, result: { mode: "image", images: [image] }, conversationId: activeConversation.id, messageId: assistantMessage.id, batchIndex, batchCount: taskCount });
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "图片已生成", resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) })).value);
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, resultStorageKeys, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "图片已生成", ...(resultUrls.length ? { resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) } : {}), ...(resultStorageKeys.length ? { resultStorageKeys: Array.from(new Set([...(current.resultStorageKeys || []), ...resultStorageKeys])) } : {}) })).value);
}, { signal: requestLifecycle.signal });
const url = runtime.generationTaskMaterializedUrls(materialized)[0];
if (!url) throw new Error("图片结果资源不可用");
@@ -752,8 +752,8 @@ export default function CreatePage() {
const taskId = Array.from(boundTaskIds)[0];
if (!taskId) throw new Error("生成任务缺少稳定任务标识");
const task = completedCreationGenerationTask(runtime, { taskId, task: boundTasks.get(taskId), mode: "video", prompt: expandedPrompt, result, conversationId: activeConversation.id, messageId: assistantMessage.id });
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "视频已生成", resultUrls })).value);
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, resultStorageKeys, effectKey }) => {
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "视频已生成", resultUrls, ...(resultStorageKeys.length ? { resultStorageKeys } : {}) })).value);
}, { signal: requestLifecycle.signal });
if (!runtime.generationTaskMaterializedUrls(materialized)[0]) throw new Error("视频结果资源不可用");
}
@@ -805,9 +805,11 @@ export default function CreatePage() {
try {
const assets = useAssetStore.getState().assets;
const generatedAssetIds = selectedAssetIds || source.messages.flatMap((item) => {
if (!item.resultUrls?.length) return [];
const ids = creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: item.resultUrls });
if (ids.length !== item.resultUrls.length) throw new Error("部分生成素材还未保存完成,请稍后转入画布。");
const resultStorageKeys = item.resultStorageKeys || [];
const resultUrls = item.resultUrls || [];
if (!resultStorageKeys.length && !resultUrls.length) return [];
const ids = creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls, resultStorageKeys });
if (ids.length !== Math.max(resultStorageKeys.length, resultUrls.length)) throw new Error("部分生成素材还未保存完成,请稍后转入画布。");
return ids;
});
const referenceKeys = new Set(source.messages.flatMap((item) => (item.attachments || []).map((attachment) => attachment.storageKey).filter(Boolean)));

View File

@@ -1,4 +1,5 @@
import { getActiveUserScope } from "@/lib/user-scope";
import { localForageStorageForScope } from "@/lib/localforage-storage";
import { http, apiBaseURL, ApiError } from "@/services/api/request";
import type { OSSConnectionTestInput, OSSConnectionTestResult, OSSProvider, S3Preset } from "@/lib/oss-settings";
@@ -117,8 +118,59 @@ export type ResourceAccess = {
fallbackReason?: string;
};
const accessCache = new Map<string, { value: ResourceAccess; expiresAt: number }>();
type ResourceAccessBatchItem = { resourceId: string; access?: ResourceAccess; error?: { msg?: string } };
type CachedResourceAccess = { value: ResourceAccess; expiresAt: number };
const accessCache = new Map<string, CachedResourceAccess>();
const accessRequests = new Map<string, Promise<ResourceAccess>>();
const PERSISTED_DISPLAY_ACCESS_PREFIX = "resource-access-v1:";
function persistedDisplayAccessKey(resourceId: string, variant: ResourceAccessVariant) {
return `${PERSISTED_DISPLAY_ACCESS_PREFIX}${resourceId}:${variant}`;
}
function shouldPersistDisplayAccess(value: ResourceAccess) {
// 只跨页面缓存 OSS/CDN 直连地址;平台本地/代理地址仍只在内存中保留,避免把平台签名凭据落到持久存储。
return value.delivery === "cdn" || value.delivery === "origin";
}
function resourceAccessCacheExpiry(value: ResourceAccess, now = Date.now()) {
const expiresAt = value.expiresAt ? new Date(value.expiresAt).getTime() : Number.NaN;
if (Number.isFinite(expiresAt) && expiresAt <= now + 1_000) return now;
const refreshAt = value.refreshAt ? new Date(value.refreshAt).getTime() : Number.NaN;
const candidates = [
Number.isFinite(refreshAt) && refreshAt > now ? refreshAt : Number.POSITIVE_INFINITY,
Number.isFinite(expiresAt) && expiresAt > now ? expiresAt - 15_000 : Number.POSITIVE_INFINITY,
].filter(Number.isFinite);
if (candidates.length) return Math.max(now, Math.min(...candidates));
return now + 5 * 60_000;
}
async function readPersistedDisplayAccess(scope: string, resourceId: string, variant: ResourceAccessVariant): Promise<CachedResourceAccess | undefined> {
try {
const raw = await localForageStorageForScope(scope).getItem(persistedDisplayAccessKey(resourceId, variant));
if (!raw) return undefined;
const parsed = JSON.parse(raw) as Partial<CachedResourceAccess>;
if (!parsed.value?.url || !shouldPersistDisplayAccess(parsed.value) || typeof parsed.expiresAt !== "number") return undefined;
const actualExpiresAt = parsed.value.expiresAt ? new Date(parsed.value.expiresAt).getTime() : Number.NaN;
if (parsed.expiresAt <= Date.now() || (Number.isFinite(actualExpiresAt) && actualExpiresAt <= Date.now() + 1_000)) {
await localForageStorageForScope(scope).removeItem(persistedDisplayAccessKey(resourceId, variant));
return undefined;
}
return parsed as CachedResourceAccess;
} catch {
// 签名地址缓存只是性能优化,存储不可用或数据损坏时继续走后端签发。
return undefined;
}
}
async function persistDisplayAccess(scope: string, resourceId: string, variant: ResourceAccessVariant, entry: CachedResourceAccess) {
if (!shouldPersistDisplayAccess(entry.value) || entry.expiresAt <= Date.now()) return;
try {
await localForageStorageForScope(scope).setItem(persistedDisplayAccessKey(resourceId, variant), JSON.stringify(entry));
} catch {
// 不把本地缓存失败升级为资源访问失败。
}
}
let accessGeneration = 0;
/**
@@ -335,15 +387,27 @@ export async function getResourceAccess(storageKey: string | undefined, purpose:
let request!: Promise<ResourceAccess>;
request = (async () => {
try {
const data = await http.post<{ items: Array<{ resourceId: string; access?: ResourceAccess; error?: { msg?: string } }> }>("/resources/access", [{ resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) }]);
// 只跨页面复用普通展示地址。下载和模型输入地址可能包含更敏感的权限/文件名,仍只保留内存缓存。
if (purpose === "display") {
const persisted = await readPersistedDisplayAccess(scope, id, variant);
assertResourceRequestCurrent(generation, scope);
if (persisted) {
accessCache.set(key, persisted);
return persisted.value;
}
}
const data = await http.post<{ items: ResourceAccessBatchItem[] }>("/resources/access", [{ resourceId: id, purpose, variant, ...(downloadName ? { downloadName } : {}) }]);
if (generation !== accessGeneration || scope !== getActiveUserScope()) {
throw new DOMException("资源访问请求已因账号切换失效", "AbortError");
}
const item = data.items?.[0];
if (!item?.access?.url) throw new Error(item?.error?.msg || "后端未返回资源访问地址");
const value = item.access;
const ttl = value.expiresAt ? Math.max(10_000, new Date(value.expiresAt).getTime() - Date.now() - 15_000) : 5 * 60_000;
accessCache.set(key, { value, expiresAt: Date.now() + ttl });
const entry = { value, expiresAt: resourceAccessCacheExpiry(value) } satisfies CachedResourceAccess;
if (entry.expiresAt > Date.now()) {
accessCache.set(key, entry);
if (purpose === "display") await persistDisplayAccess(scope, id, variant, entry);
}
return value;
} catch (error) {
if (error instanceof ApiError) throw new Error(error.message || "获取对象存储地址失败");

View File

@@ -63,7 +63,19 @@ export async function loadCreationConversations<T extends StoredCreationConversa
return parsed as T[];
}
function persistableCreationConversations<T extends StoredCreationConversation>(conversations: T[]) {
return conversations.map((conversation) => ({
...conversation,
messages: conversation.messages.map((message) => {
const candidate = message as PendingCreationMessage & { resultUrls?: unknown; resultStorageKeys?: unknown };
if (!Array.isArray(candidate.resultStorageKeys) || candidate.resultStorageKeys.length === 0) return message;
const { resultUrls: _transientResultUrls, ...persistedMessage } = candidate;
return persistedMessage as typeof message;
}),
})) as T[];
}
export async function saveCreationConversations<T extends StoredCreationConversation>(conversations: T[]) {
const storage = localForageStorageForScope(getActiveUserScope());
await storage.setItem(CREATION_CONVERSATIONS_KEY, JSON.stringify(conversations));
await storage.setItem(CREATION_CONVERSATIONS_KEY, JSON.stringify(persistableCreationConversations(conversations)));
}

View File

@@ -520,12 +520,13 @@ export async function consumeGenerationTaskAgent(
export async function consumeGenerationTaskMessage(
task: GenerationTask,
messageId: string,
consumer: (input: { task: GenerationTask; resultUrls: string[]; effectKey: string; signal?: AbortSignal }) => Promise<void> | void,
consumer: (input: { task: GenerationTask; resultUrls: string[]; resultStorageKeys: string[]; effectKey: string; signal?: AbortSignal }) => Promise<void> | void,
dependencies: {
signal?: AbortSignal;
managed?: true;
materialize?: typeof materializeGenerationTaskAssets;
materializedUrls?: typeof generationTaskMaterializedUrls;
materializedStorageKeys?: typeof generationTaskMaterializedStorageKeys;
attachMessage?: typeof attachGenerationTaskMessage;
} = {},
): Promise<GenerationTask> {
@@ -534,6 +535,7 @@ export async function consumeGenerationTaskMessage(
}
const materialized = await (dependencies.materialize ?? materializeGenerationTaskAssets)(task, dependencies.signal);
const resultUrls = (dependencies.materializedUrls ?? generationTaskMaterializedUrls)(materialized);
const resultStorageKeys = (dependencies.materializedStorageKeys ?? generationTaskMaterializedStorageKeys)(materialized);
const attach = dependencies.attachMessage ?? attachGenerationTaskMessage;
const outputs = materialized.outputs?.filter((output) => output.materializedAssetId) ?? [];
for (const output of outputs) {
@@ -542,7 +544,7 @@ export async function consumeGenerationTaskMessage(
messageId,
output.outputIndex,
async ({ effectKey, signal }) => {
await consumer({ task: materialized, resultUrls, effectKey, signal });
await consumer({ task: materialized, resultUrls, resultStorageKeys, effectKey, signal });
},
dependencies.signal,
);
@@ -555,8 +557,21 @@ export function generationTaskMaterializedUrls(task: GenerationTask): string[] {
return (task.outputs || []).flatMap((output) => {
const asset = output.materializedAssetId ? assets.find((candidate) => candidate.id === output.materializedAssetId) : undefined;
if (!asset) return [];
if (asset.kind === "image") return [asset.data.dataUrl || asset.coverUrl];
if (asset.kind === "video" || asset.kind === "audio") return [asset.data.url];
if (asset.kind === "image") return [asset.data.dataUrl || asset.coverUrl].filter(Boolean);
if (asset.kind === "video" || asset.kind === "audio") return [asset.data.url].filter(Boolean);
return [];
});
}
/**
* 返回生成结果的稳定素材定位符。对于远程资源是 resource:<id>,对于本地降级素材是
* 本地 IndexedDB storageKey。它可以持久化到历史记录,不能被短时效签名 URL 替代。
*/
export function generationTaskMaterializedStorageKeys(task: GenerationTask): string[] {
const assets = useAssetStore.getState().assets;
return (task.outputs || []).flatMap((output) => {
const asset = output.materializedAssetId ? assets.find((candidate) => candidate.id === output.materializedAssetId) : undefined;
if (!asset || (asset.kind !== "image" && asset.kind !== "video" && asset.kind !== "audio")) return [];
return asset.data.storageKey ? [asset.data.storageKey] : [];
});
}

View File

@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { AgentOperationFeed, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
import { AgentOperationFeed, AgentReasoningFeed, type CloudAgentChatMessage } from "@/components/canvas/canvas-cloud-agent-chat-ui";
import { buildAgentFeedSegments } from "@/lib/canvas/agent-operation-feed";
import { canvasThemes } from "@/lib/canvas-theme";
const step = (id: string, title: string, text: string, detail: unknown = { eventType: "tool_completed" }): CloudAgentChatMessage => ({ id, role: "tool", title, text, detail });
@@ -8,6 +9,22 @@ const step = (id: string, title: string, text: string, detail: unknown = { event
const steps: CloudAgentChatMessage[] = [step("t1", "canvas_get_state", "工具执行成功"), step("t2", "model_list", "工具执行成功")];
const viewed = (id: string, title: string): CloudAgentChatMessage => step(id, "canvas_inspect_image", "工具执行成功", { eventType: "tool_completed", result: { nodeId: id, title } });
test("consecutive reasoning messages share one collapsed entry", () => {
const messages: CloudAgentChatMessage[] = [
{ id: "r1", role: "assistant", text: "先检查画布", reasoning: true },
{ id: "r2", role: "assistant", text: "再选择模型", reasoning: true },
{ id: "a1", role: "assistant", text: "我开始处理。" },
];
const segments = buildAgentFeedSegments(messages);
expect(segments.map((segment) => segment.kind)).toEqual(["reasoning", "message"]);
expect(segments[0]?.kind === "reasoning" ? segments[0].items : []).toHaveLength(2);
const html = renderToStaticMarkup(<AgentReasoningFeed items={messages.slice(0, 2)} theme={canvasThemes.light} />);
expect(html).toContain("2 段 · 点击查看");
expect(html).toContain('class="agent-reasoning-card"');
expect(html).not.toContain(" open");
});
test("operations fold into one line that reports only the latest action", () => {
for (const theme of [canvasThemes.light, canvasThemes.dark]) {
const html = renderToStaticMarkup(<AgentOperationFeed items={steps} theme={theme} />);
@@ -74,7 +91,7 @@ test("style contract: no per-step status ticks, vision tint and shimmer stay tok
expect(css).toContain("--agent-tool-accent: var(--agent-accent, var(--foreground));");
});
test("style contract: 正文 / 工具调用 / 模型思考 三档靠公共轴与明度分层", async () => {
test("style contract: 正文 / 工具调用 / 模型思考三档不再制造左侧公共竖轨", async () => {
const css = await Bun.file(new URL("../src/components/canvas/canvas-cloud-agent.css", import.meta.url)).text();
// 同一选择器可能在容器查询里被覆盖,这里把所有命中块拼起来看整体契约。
const block = (selector: string) => [...css.matchAll(new RegExp(`${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\{([^}]*)\\}`, "g"))].map((match) => match[1]).join("\n");
@@ -84,27 +101,23 @@ test("style contract: 正文 / 工具调用 / 模型思考 三档靠公共轴与
expect(icon).toContain("background: transparent");
expect(icon).not.toContain("border-radius: 50%");
// ② 公共轴:正文就从这条线开始(列表左内边距 = 轴)
// ② 对话正文从容器边缘开始,不为过程内容预留一条空的公共轴沟槽。
const axis = block(".agent-conversation-messages");
expect(axis).toContain("--agent-axis: 22px");
expect(axis).toContain("padding-left: var(--agent-axis)");
expect(axis).not.toContain("--agent-axis");
expect(axis).not.toContain("padding-left: var(--agent-axis)");
// ③ 过程标记挂在轴上:星标向左挂半格 + 摘要内边距,中心正好落在轴
// ③ 思维摘要与正文同一左边缘,不再用负 margin 把星标挂进沟槽。
const reasoning = block(".agent-reasoning");
expect(reasoning).toContain("margin-left: calc(-1 * (var(--agent-thought-icon, 16px) / 2 + var(--agent-summary-inset, 4px)))");
expect(reasoning).not.toContain("margin-left");
expect(reasoning).not.toContain("border-left");
expect(block(".agent-reasoning-summary")).toContain("padding: 6px var(--agent-summary-inset");
expect(block(".agent-reasoning-summary")).toContain("padding: 6px 0");
// ④ 工具左轨也压在轴上(1px 线居中),内容落在轨右侧
// ④ 操作流是轻量文本收据,不再绘制竖线或额外左缩进。
const feed = block(".agent-operation-feed");
expect(feed).toContain("margin-left: -0.5px");
expect(feed).toContain("border-left: 1px solid");
expect(feed).toContain("padding-left: var(--agent-activity-inset");
expect(feed).not.toContain("border-left");
expect(feed).not.toContain("padding-left");
// ⑤ 时间线标记同样挂半格,中心与星标/左轨同轴
expect(block(".agent-timeline-marker")).toContain("width: var(--agent-timeline-marker, 24px)");
expect(block(".agent-conversation-messages .agent-timeline-marker")).toContain("margin-left: calc(-1 * (var(--agent-timeline-marker, 24px) / 2))");
// ⑥ 窄面板只把轴收细,不让三处错位
expect(css).toContain("--agent-axis: 16px");
// ⑤ 时间线标记只占自己的窄图标列,不再把状态正文推得过远。
expect(block(".agent-timeline-marker")).toContain("width: 20px");
expect(block(".agent-conversation-messages .agent-timeline-marker")).not.toContain("margin-left");
});