From b3199c5e7eb28cfabceb33e59f29880403f1f129 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Thu, 6 Aug 2026 16:36:51 +0800 Subject: [PATCH] =?UTF-8?q?refactor(*):=20=E4=BB=A3=E7=A0=81=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E6=B2=BB=E7=90=86=20-=20=E6=94=B6=E6=95=9B=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=AE=A2=E6=88=B7=E7=AB=AF=E3=80=81=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=81=8C=E8=B4=A3=E4=B8=8E=E5=BC=82=E6=AD=A5=E8=AE=A1=E8=B4=B9?= =?UTF-8?q?=E5=B7=A1=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/quality.yml | 48 + .gitignore | 4 + README.md | 2 +- backend/internal/handler/routes.go | 1 + backend/internal/model/models.go | 709 -------- backend/internal/model/models_channel.go | 94 + backend/internal/model/models_finance.go | 116 ++ backend/internal/model/models_identity.go | 57 + backend/internal/model/models_platform.go | 77 + backend/internal/model/models_project.go | 279 +++ backend/internal/model/models_task.go | 105 ++ backend/internal/repository/billing_review.go | 68 + backend/internal/service/billing_review.go | 44 + backend/internal/service/provider.go | 416 ++--- .../service/provider_request_types.go | 85 + .../service/provider_video_options.go | 253 +++ backend/internal/service/service.go | 108 +- .../service/service_workflow_results.go | 65 + web/package.json | 2 + web/src/components/audio-settings-panel.tsx | 10 +- .../canvas/canvas-emotion-workspace.tsx | 234 ++- .../canvas/canvas-node-prompt-panel.tsx | 183 +- .../canvas/canvas-preset-picker.tsx | 112 +- .../canvas/canvas-project-sidebar.tsx | 200 ++- web/src/components/model-picker.tsx | 143 +- .../ui/aceternity/floating-dock.tsx | 39 +- .../ui/aceternity/full-screen-loader.tsx | 9 +- .../pages/canvas/canvas-project-feedback.tsx | 89 +- web/src/pages/canvas/canvas-refresh-shell.tsx | 38 + web/src/pages/canvas/project.tsx | 1539 +++++++++-------- web/src/services/api/announcements.ts | 12 +- web/src/services/api/auth.ts | 17 +- web/src/services/api/canvas-share.ts | 12 +- web/src/services/api/projects.ts | 16 +- web/src/services/api/request.ts | 25 + web/src/services/api/resources.ts | 14 +- web/src/services/api/skills.ts | 16 +- web/src/services/api/task-center.ts | 36 +- web/src/services/api/user-data.ts | 16 +- web/src/services/api/wallet.ts | 17 +- web/src/styles/globals.css | 10 + web/test/canvas-drawing-engine.test.ts | 2 +- 42 files changed, 3051 insertions(+), 2271 deletions(-) create mode 100644 .github/workflows/quality.yml create mode 100644 backend/internal/model/models_channel.go create mode 100644 backend/internal/model/models_finance.go create mode 100644 backend/internal/model/models_identity.go create mode 100644 backend/internal/model/models_platform.go create mode 100644 backend/internal/model/models_project.go create mode 100644 backend/internal/model/models_task.go create mode 100644 backend/internal/repository/billing_review.go create mode 100644 backend/internal/service/billing_review.go create mode 100644 backend/internal/service/provider_request_types.go create mode 100644 backend/internal/service/provider_video_options.go create mode 100644 backend/internal/service/service_workflow_results.go create mode 100644 web/src/pages/canvas/canvas-refresh-shell.tsx diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 000000000..01bece97c --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,48 @@ +name: Quality checks + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + backend: + name: Backend checks + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: backend/go.mod + cache: true + - name: Check formatting + run: test -z "$(gofmt -l .)" + - name: Run Go tests + run: go test ./... + + web: + name: Web checks + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.9 + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Check formatting + run: bun run format:check + - name: Typecheck + run: bun run typecheck + - name: Run tests + run: bun run test diff --git a/.gitignore b/.gitignore index b824d1466..73af2608a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ dist/ *.sqlite3 *.bak *.tmp +/*.drawio.bkp +/prompt-preferences-*-before.png +/prompt*-before.png +assets/prototypes/.*.drawio.bkp .settings-key .DS_Store .idea/ diff --git a/README.md b/README.md index 22756e085..13ccc3425 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

GitHub stars - Version + Version License

diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index 417344da8..5e8ad75f6 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -334,6 +334,7 @@ func RegisterSessionRoutes(r *gin.RouterGroup, svc *service.Service) { r.GET("/sessions/:id", querySession) r.POST("/files", uploadFile) r.GET("/sessions/:id/results", downloadResults) + // 兼容旧客户端的废弃路由;新调用统一使用上方 REST 风格路径。 r.POST("/create_session", createSession) r.GET("/query_session/:id", querySession) r.POST("/upload_file", uploadFile) diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index ed47eab1b..3aade4cc1 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -2,8 +2,6 @@ package model import ( "time" - - "gorm.io/gorm" ) type TaskStatus string @@ -149,710 +147,3 @@ const ( WorkflowStepStatusFailed WorkflowStepStatus = "failed" WorkflowStepStatusSkipped WorkflowStepStatus = "skipped" ) - -type User struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Username string `json:"username" gorm:"uniqueIndex;size:80"` - Email string `json:"email,omitempty" gorm:"size:160"` - DisplayName string `json:"displayName" gorm:"size:80"` - Role UserRole `json:"role" gorm:"index;size:24"` - Status UserStatus `json:"status" gorm:"index;size:24"` - PasswordHash string `json:"-"` - LastLoginAt *time.Time `json:"lastLoginAt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type AuthSession struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - TokenHash string `json:"-"` - ExpiresAt time.Time `json:"expiresAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type UserIdentity struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - Provider string `json:"provider" gorm:"size:32;uniqueIndex:idx_user_identity_provider_subject,priority:1"` - Subject string `json:"subject" gorm:"size:160;uniqueIndex:idx_user_identity_provider_subject,priority:2"` - ProviderUsername string `json:"providerUsername" gorm:"size:160"` - AvatarURL string `json:"avatarUrl"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type OAuthState struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Provider string `json:"provider" gorm:"index;size:32"` - StateHash string `json:"-" gorm:"uniqueIndex;size:64"` - CodeVerifier string `json:"-" gorm:"size:160"` - NextPath string `json:"nextPath"` - ExpiresAt time.Time `json:"expiresAt" gorm:"index"` - UsedAt *time.Time `json:"usedAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt"` -} - -type EmailVerificationCode struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Email string `json:"email" gorm:"index;size:160"` - CodeHash string `json:"-" gorm:"size:64"` - Purpose string `json:"purpose" gorm:"index;size:32"` - ExpiresAt time.Time `json:"expiresAt" gorm:"index"` - UsedAt *time.Time `json:"usedAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` -} - -type ModelChannel struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - Scope ChannelScope `json:"scope" gorm:"index;size:24"` - Enabled bool `json:"enabled" gorm:"index"` - Name string `json:"name" gorm:"size:80"` - BaseURL string `json:"baseUrl"` - APIKey string `json:"-"` - SecretKey string `json:"-"` - APIFormat string `json:"apiFormat" gorm:"size:24"` - ConcurrencyLimit int `json:"concurrencyLimit"` - ModelsJSON string `json:"modelsJson" gorm:"type:text"` - HeadersJSON string `json:"-" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` -} - -type ChannelModel struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"` - ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"` - DisplayName string `json:"displayName" gorm:"size:160"` - Capability string `json:"capability" gorm:"size:32;index"` - Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"` - BillingMode string `json:"billingMode" gorm:"size:32"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` - OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` - CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` - PriceConfigured bool `json:"priceConfigured" gorm:"index"` - Enabled bool `json:"enabled" gorm:"index"` - PriceVersion int64 `json:"priceVersion"` - CapabilityConfigJSON string `json:"-" gorm:"type:text"` - CapabilityVersion int64 `json:"capabilityVersion"` - CapabilityConfig map[string]any `json:"capabilityConfig,omitempty" gorm:"-"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` -} - -type ApiCallLog struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_api_logs_user_created,priority:1"` - UserDisplayName string `json:"userDisplayName,omitempty" gorm:"-"` - UserAccount string `json:"userAccount,omitempty" gorm:"-"` - ChannelID string `json:"channelId" gorm:"index;size:36;index:idx_api_logs_channel_created,priority:1"` - ChannelName string `json:"channelName" gorm:"-"` - TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` - TaskStatus TaskStatus `json:"taskStatus,omitempty" gorm:"-"` - BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` - Source string `json:"source" gorm:"index;size:64"` - Capability string `json:"capability" gorm:"index;size:32"` - Operation string `json:"operation" gorm:"size:64"` - RequestKind string `json:"requestKind" gorm:"index;size:24"` - Billable bool `json:"billable" gorm:"index"` - APIFormat string `json:"apiFormat" gorm:"size:24"` - Method string `json:"method" gorm:"size:16"` - Path string `json:"path"` - Model string `json:"model" gorm:"size:120;index:idx_api_logs_model_created,priority:1"` - Status ApiCallStatus `json:"status" gorm:"index;size:24;index:idx_api_logs_status_created,priority:1"` - StatusCode int `json:"statusCode"` - DurationMs int64 `json:"durationMs"` - PollCount int `json:"pollCount"` - ProviderStatus string `json:"providerStatus,omitempty" gorm:"size:32"` - InputTokens int64 `json:"inputTokens"` - OutputTokens int64 `json:"outputTokens"` - CachedTokens int64 `json:"cachedTokens"` - UsageAvailable bool `json:"usageAvailable"` - MediaCount int `json:"mediaCount"` - MediaPreviewURL string `json:"mediaPreviewUrl,omitempty" gorm:"-"` - MediaPreviewKind string `json:"mediaPreviewKind,omitempty" gorm:"-"` - VideoSeconds int `json:"videoSeconds"` - ProviderRequestID string `json:"providerRequestId" gorm:"size:160"` - EstimatedCostMicros int64 `json:"estimatedCostMicros"` - CostAvailable bool `json:"costAvailable"` - Currency string `json:"currency" gorm:"size:12"` - ErrorCode string `json:"errorCode,omitempty" gorm:"index;size:80"` - Error string `json:"error"` - ConcurrencyLimit int `json:"concurrencyLimit"` - UpstreamURL string `json:"upstreamUrl"` - RequestContentType string `json:"requestContentType,omitempty" gorm:"size:160"` - RequestBody string `json:"requestBody,omitempty" gorm:"type:text"` - ResponseBody string `json:"responseBody,omitempty" gorm:"type:text"` - StartedAt time.Time `json:"startedAt"` - CreatedAt time.Time `json:"createdAt" gorm:"index;index:idx_api_logs_user_created,priority:2;index:idx_api_logs_channel_created,priority:2;index:idx_api_logs_model_created,priority:2;index:idx_api_logs_status_created,priority:2"` -} - -type CreditAccount struct { - UserID string `json:"userId" gorm:"primaryKey;size:36"` - AvailableMicrocredits int64 `json:"availableMicrocredits"` - ReservedMicrocredits int64 `json:"reservedMicrocredits"` - Version int64 `json:"version"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type CreditLedgerEntry struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"size:36;index;index:idx_credit_ledger_user_created,priority:1"` - Type CreditLedgerType `json:"type" gorm:"size:32;index"` - AmountMicrocredits int64 `json:"amountMicrocredits"` - AvailableDeltaMicrocredits int64 `json:"availableDeltaMicrocredits"` - ReservedDeltaMicrocredits int64 `json:"reservedDeltaMicrocredits"` - AvailableAfterMicrocredits int64 `json:"availableAfterMicrocredits"` - ReservedAfterMicrocredits int64 `json:"reservedAfterMicrocredits"` - BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` - RedeemCodeID string `json:"redeemCodeId,omitempty" gorm:"index;size:36"` - ActorUserID string `json:"actorUserId,omitempty" gorm:"index;size:36"` - Model string `json:"model,omitempty" gorm:"size:120;index"` - ChannelID string `json:"channelId,omitempty" gorm:"size:36;index"` - Scene string `json:"scene,omitempty" gorm:"size:80;index"` - Note string `json:"note,omitempty" gorm:"size:500"` - ReferenceKey *string `json:"referenceKey,omitempty" gorm:"size:180;uniqueIndex"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_credit_ledger_user_created,priority:2"` -} - -type BillingOrder struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_billing_user_idempotency,priority:1"` - IdempotencyKey string `json:"idempotencyKey" gorm:"size:160;uniqueIndex:idx_billing_user_idempotency,priority:2"` - TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` - ChannelID string `json:"channelId" gorm:"index;size:36"` - ChannelModelID string `json:"channelModelId" gorm:"index;size:36"` - Model string `json:"model" gorm:"index;size:120"` - Capability string `json:"capability" gorm:"index;size:32"` - Scene string `json:"scene" gorm:"index;size:80"` - BillingMode string `json:"billingMode" gorm:"size:32"` - PriceVersion int64 `json:"priceVersion"` - UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` - MultiplierBasisPoints int64 `json:"multiplierBasisPoints"` - Quantity int64 `json:"quantity"` - AmountMicrocredits int64 `json:"amountMicrocredits"` - ReservedAmountMicrocredits int64 `json:"reservedAmountMicrocredits"` - ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits"` - RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits"` - InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` - OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` - CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` - InputTokens int64 `json:"inputTokens"` - OutputTokens int64 `json:"outputTokens"` - CachedTokens int64 `json:"cachedTokens"` - UsageAvailable bool `json:"usageAvailable"` - Status BillingStatus `json:"status" gorm:"index;size:24"` - ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` - Error string `json:"error,omitempty" gorm:"size:1000"` - ResolvedBy string `json:"resolvedBy,omitempty" gorm:"index;size:36"` - ResolutionNote string `json:"resolutionNote,omitempty" gorm:"size:500"` - StartedAt *time.Time `json:"startedAt"` - SettledAt *time.Time `json:"settledAt"` - RefundedAt *time.Time `json:"refundedAt"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type RedeemBatch struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - AmountMicrocredits int64 `json:"amountMicrocredits"` - Count int `json:"count"` - Note string `json:"note" gorm:"size:500"` - CreatedBy string `json:"createdBy" gorm:"index;size:36"` - CodesCipher string `json:"-" gorm:"type:text"` - ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` - AvailableCount int64 `json:"availableCount" gorm:"->;-:migration"` - RedeemedCount int64 `json:"redeemedCount" gorm:"->;-:migration"` - DisabledCount int64 `json:"disabledCount" gorm:"->;-:migration"` - ExpiredCount int64 `json:"expiredCount" gorm:"->;-:migration"` -} - -type RedeemCode struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - BatchID string `json:"batchId" gorm:"index;size:36;index:idx_redeem_codes_batch_status,priority:1;index:idx_redeem_codes_batch_created,priority:1"` - CodeHash string `json:"-" gorm:"uniqueIndex;size:64"` - CodeSuffix string `json:"codeSuffix" gorm:"size:4"` - AmountMicrocredits int64 `json:"amountMicrocredits"` - Status RedeemCodeStatus `json:"status" gorm:"index;size:24;index:idx_redeem_codes_batch_status,priority:2"` - RedeemedBy string `json:"redeemedBy,omitempty" gorm:"index;size:36"` - RedeemedAt *time.Time `json:"redeemedAt"` - RedeemedIP string `json:"redeemedIp,omitempty" gorm:"size:64"` - ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_redeem_codes_batch_created,priority:2"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type ModelPricing struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ChannelID string `json:"channelId" gorm:"size:36;uniqueIndex:idx_model_pricing_scope,priority:1"` - Model string `json:"model" gorm:"size:120;uniqueIndex:idx_model_pricing_scope,priority:2"` - Capability string `json:"capability" gorm:"size:32;uniqueIndex:idx_model_pricing_scope,priority:3"` - Currency string `json:"currency" gorm:"size:12"` - InputPerMillionMicros int64 `json:"inputPerMillionMicros"` - OutputPerMillionMicros int64 `json:"outputPerMillionMicros"` - CachedPerMillionMicros int64 `json:"cachedPerMillionMicros"` - PerRequestMicros int64 `json:"perRequestMicros"` - PerMediaMicros int64 `json:"perMediaMicros"` - PerVideoSecondMicros int64 `json:"perVideoSecondMicros"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type UserDailyActivity struct { - ID string `json:"id" gorm:"primaryKey;size:64"` - Day time.Time `json:"day" gorm:"type:date;uniqueIndex:idx_user_daily_activity_day_user,priority:1;index"` - UserID string `json:"userId" gorm:"size:36;uniqueIndex:idx_user_daily_activity_day_user,priority:2;index"` - FirstActiveAt *time.Time `json:"firstActiveAt"` - LastActiveAt *time.Time `json:"lastActiveAt"` - LoginCount int `json:"loginCount"` - TaskCount int `json:"taskCount"` - AgentMessageCount int `json:"agentMessageCount"` - CanvasActive bool `json:"canvasActive"` - AssetCount int `json:"assetCount"` - ResourceCount int `json:"resourceCount"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type SystemSetting struct { - Key string `json:"key" gorm:"primaryKey;size:80"` - ValueJSON string `json:"valueJson" gorm:"type:text"` - UpdatedBy string `json:"updatedBy" gorm:"index;size:36"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type UserOSSSetting struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_user_oss_settings_user_created,priority:1"` - Enabled bool `json:"enabled" gorm:"index"` - ValueJSON string `json:"-" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_user_oss_settings_user_created,priority:2"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type UserDailyUploadUsage struct { - ID string `json:"id" gorm:"primaryKey;size:64"` - UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_user_daily_upload_day,priority:1"` - Day string `json:"day" gorm:"size:10;index;uniqueIndex:idx_user_daily_upload_day,priority:2"` - Bytes int64 `json:"bytes"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Skill struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - OwnerID string `json:"ownerId" gorm:"index;size:36"` - AuthorName string `json:"authorName" gorm:"size:120;index"` - AuthorAvatarURL string `json:"authorAvatarUrl" gorm:"size:1000"` - Name string `json:"name" gorm:"size:80;index"` - Description string `json:"description" gorm:"size:500"` - Instruction string `json:"instruction" gorm:"type:text"` - Status int `json:"status" gorm:"index"` - Source int `json:"source" gorm:"index"` - Tag string `json:"tag" gorm:"size:32;index"` - SortWeight int `json:"sortWeight" gorm:"index"` - IsPrivate bool `json:"isPrivate" gorm:"index"` - MarkdownURL string `json:"markdownUrl" gorm:"size:500"` - ShowcaseMediaJSON string `json:"-" gorm:"type:text"` - ExtraInfo string `json:"extraInfo" gorm:"type:text"` - InitialLikeCount int64 `json:"initialLikeCount"` - InitialAddedCount int64 `json:"initialAddedCount"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` - UpdatedAt time.Time `json:"updatedAt" gorm:"index"` -} - -type UserSkillState struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_user_skill_state_user_skill,priority:1"` - SkillID string `json:"skillId" gorm:"size:36;index;uniqueIndex:idx_user_skill_state_user_skill,priority:2"` - Added bool `json:"added" gorm:"index"` - Liked bool `json:"liked" gorm:"index"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Resource struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_resources_user_created,priority:1"` - Kind string `json:"kind" gorm:"index;size:24"` - Status ResourceStatus `json:"status" gorm:"index;size:24"` - Provider string `json:"provider" gorm:"size:24"` - Endpoint string `json:"endpoint"` - Bucket string `json:"bucket" gorm:"size:160"` - // 用户 OSS 每次修改都会生成新版本,资源固定引用创建时的版本,避免历史资源因换密钥失效。 - StorageSettingID string `json:"-" gorm:"index;size:36"` - ObjectKey string `json:"objectKey" gorm:"index"` - PublicURL string `json:"publicUrl"` - MimeType string `json:"mimeType" gorm:"size:120"` - Size int64 `json:"size"` - Width int `json:"width"` - Height int `json:"height"` - DurationMs int64 `json:"durationMs"` - ETag string `json:"etag" gorm:"size:160"` - Error string `json:"error"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_resources_user_created,priority:2"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Asset struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_assets_user_updated,priority:1"` - Kind string `json:"kind" gorm:"index;size:24"` - Category AssetCategory `json:"category" gorm:"index;size:32"` - Status AssetVersionStatus `json:"status" gorm:"index;size:24"` - PrimaryVersionID string `json:"primaryVersionId,omitempty" gorm:"index;size:36"` - Title string `json:"title" gorm:"size:240"` - PayloadJSON string `json:"payloadJson" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_assets_user_updated,priority:2"` -} - -type ProjectAssetLink struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1"` - AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:2"` - CreatedAt time.Time `json:"createdAt"` -} - -type ProjectAssetCandidate struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36"` - UnitID string `json:"unitId,omitempty" gorm:"index;size:36"` - ShotID string `json:"shotId,omitempty" gorm:"index;size:36"` - Name string `json:"name" gorm:"size:240"` - Category AssetCategory `json:"category" gorm:"index;size:32"` - Status string `json:"status" gorm:"index;size:32"` - DetailsJSON string `json:"detailsJson" gorm:"type:text"` - ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:36"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type AssetVersion struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_asset_versions_number,priority:1"` - Version int `json:"version" gorm:"uniqueIndex:idx_asset_versions_number,priority:2"` - Status AssetVersionStatus `json:"status" gorm:"index;size:24"` - DefinitionJSON string `json:"definitionJson" gorm:"type:text"` - Prompt string `json:"prompt" gorm:"type:text"` - Note string `json:"note" gorm:"size:500"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type AssetRepresentation struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - TaskID string `json:"taskId,omitempty" gorm:"index;size:36;uniqueIndex:idx_asset_representations_task_role,priority:1"` - AssetVersionID string `json:"assetVersionId" gorm:"index;size:36;uniqueIndex:idx_asset_representations_version_role,priority:1"` - ResourceID string `json:"resourceId,omitempty" gorm:"index;size:36"` - MediaType string `json:"mediaType" gorm:"index;size:24"` - Role string `json:"role" gorm:"index;size:32;uniqueIndex:idx_asset_representations_task_role,priority:2;uniqueIndex:idx_asset_representations_version_role,priority:2"` - MetadataJSON string `json:"metadataJson" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` -} - -// VoiceProfile 是可复用的声音身份;试听音频只是表现资源,不等同于声音本身。 -type VoiceProfile struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_voice_profiles_user_provider_key,priority:1"` - Name string `json:"name" gorm:"size:160"` - Provider string `json:"provider" gorm:"size:48;uniqueIndex:idx_voice_profiles_user_provider_key,priority:2"` - VoiceKey string `json:"voiceKey" gorm:"size:160;uniqueIndex:idx_voice_profiles_user_provider_key,priority:3"` - Language string `json:"language" gorm:"size:80"` - Timbre string `json:"timbre" gorm:"size:240"` - SampleResourceID string `json:"sampleResourceId,omitempty" gorm:"index;size:36"` - CompatibleModelsJSON string `json:"compatibleModelsJson" gorm:"type:text"` - Status string `json:"status" gorm:"index;size:24"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type CharacterVoiceBinding struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - AssetVersionID string `json:"assetVersionId" gorm:"uniqueIndex;size:36"` - VoiceProfileID string `json:"voiceProfileId" gorm:"index;size:36"` - Instructions string `json:"instructions" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// Project 是短剧领域聚合根;CanvasProject 仍代表可独立创作的画布文档。 -type Project struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_projects_user_name,priority:1"` - Name string `json:"name" gorm:"size:240;uniqueIndex:idx_projects_user_name,priority:2"` - Type string `json:"type" gorm:"size:32;index"` - AspectRatio string `json:"aspectRatio" gorm:"size:16"` - SourceType string `json:"sourceType" gorm:"size:32"` - Description string `json:"description" gorm:"type:text"` - StylePresetID string `json:"stylePresetId" gorm:"size:64"` - Status ProjectStatus `json:"status" gorm:"index;size:24"` - Revision int64 `json:"revision"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt" gorm:"index"` -} - -type ProjectUnit struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36"` - ParentID string `json:"parentId,omitempty" gorm:"index;size:36"` - Kind ProjectUnitKind `json:"kind" gorm:"index;size:24"` - Title string `json:"title" gorm:"size:240"` - SourceText string `json:"sourceText" gorm:"type:text"` - Status ProjectUnitStatus `json:"status" gorm:"index;size:24"` - Position int `json:"position"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type CanvasUnitLink struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:1"` - CanvasID string `json:"canvasId" gorm:"index;size:80;uniqueIndex:idx_canvas_unit_links_unique,priority:2"` - UnitID string `json:"unitId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:3"` - Role string `json:"role" gorm:"size:32"` - CreatedAt time.Time `json:"createdAt"` -} - -type Shot struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36"` - UnitID string `json:"unitId" gorm:"index;size:36"` - Title string `json:"title" gorm:"size:240"` - Description string `json:"description" gorm:"type:text"` - Position int `json:"position"` - DurationMs int64 `json:"durationMs"` - Status string `json:"status" gorm:"index;size:24"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type ShotAssetReference struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ShotID string `json:"shotId" gorm:"index;size:36;uniqueIndex:idx_shot_asset_reference_unique,priority:1"` - AssetVersionID string `json:"assetVersionId" gorm:"index;size:36;uniqueIndex:idx_shot_asset_reference_unique,priority:2"` - Role string `json:"role" gorm:"index;size:32;uniqueIndex:idx_shot_asset_reference_unique,priority:3"` - Status string `json:"status" gorm:"index;size:24"` - CreatedAt time.Time `json:"createdAt"` -} - -type WorkflowTemplateVersion struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - TemplateKey string `json:"templateKey" gorm:"size:80;uniqueIndex:idx_workflow_template_version,priority:1"` - Name string `json:"name" gorm:"size:160"` - Version int `json:"version" gorm:"uniqueIndex:idx_workflow_template_version,priority:2"` - DefinitionJSON string `json:"definitionJson" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` -} - -type WorkflowInstance struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:1"` - UnitID string `json:"unitId,omitempty" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:2"` - TemplateVersionID string `json:"templateVersionId" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:3"` - Scope string `json:"scope" gorm:"index;size:24"` - Status WorkflowStatus `json:"status" gorm:"index;size:24"` - Revision int64 `json:"revision"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type WorkflowStepInstance struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - WorkflowInstanceID string `json:"workflowInstanceId" gorm:"index;size:36;uniqueIndex:idx_workflow_steps_instance_key,priority:1"` - StepKey string `json:"stepKey" gorm:"size:80;uniqueIndex:idx_workflow_steps_instance_key,priority:2"` - Name string `json:"name" gorm:"size:160"` - Position int `json:"position"` - Status WorkflowStepStatus `json:"status" gorm:"index;size:24"` - InputJSON string `json:"inputJson" gorm:"type:text"` - OutputJSON string `json:"outputJson" gorm:"type:text"` - Error string `json:"error" gorm:"type:text"` - StartedAt *time.Time `json:"startedAt"` - CompletedAt *time.Time `json:"completedAt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type WorkflowStepTask struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - WorkflowStepID string `json:"workflowStepId" gorm:"index;size:36;uniqueIndex:idx_workflow_step_tasks_unique,priority:1"` - TaskID string `json:"taskId" gorm:"index;size:36;uniqueIndex:idx_workflow_step_tasks_unique,priority:2"` - CreatedAt time.Time `json:"createdAt"` -} - -type CanvasProject struct { - ID string `json:"id" gorm:"primaryKey;size:80"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_canvas_projects_user_updated,priority:1"` - ProjectID string `json:"projectId,omitempty" gorm:"index;size:36"` - Title string `json:"title" gorm:"size:240"` - PayloadJSON string `json:"payloadJson" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_canvas_projects_user_updated,priority:2"` -} - -type CanvasShare struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_canvas_share_owner_project,priority:1"` - ProjectID string `json:"projectId" gorm:"index;size:80;uniqueIndex:idx_canvas_share_owner_project,priority:2"` - TokenHash string `json:"-" gorm:"uniqueIndex;size:64"` - TokenCipher string `json:"-" gorm:"type:text"` - Enabled bool `json:"enabled" gorm:"index"` - ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type PromptTemplate struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Operation string `json:"operation" gorm:"size:64;index;uniqueIndex:idx_prompt_template_operation_version,priority:1"` - Name string `json:"name" gorm:"size:120"` - Version int `json:"version" gorm:"uniqueIndex:idx_prompt_template_operation_version,priority:2"` - Content string `json:"content" gorm:"type:text"` - OutputType string `json:"outputType" gorm:"size:24"` - Enabled bool `json:"enabled" gorm:"index;index:idx_prompt_template_active,priority:2"` - CreatedBy string `json:"createdBy" gorm:"index;size:36"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// UserPromptCustomization 只保存用户的创作策略层,动态上下文和输出契约始终由服务端编译器注入。 -type UserPromptCustomization struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_user_prompt_operation,priority:1"` - Operation string `json:"operation" gorm:"size:64;index;uniqueIndex:idx_user_prompt_operation,priority:2"` - Mode string `json:"mode" gorm:"size:24"` - Content string `json:"content" gorm:"type:text"` - BaseTemplateID string `json:"baseTemplateId" gorm:"size:36;index"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Announcement struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - Title string `json:"title" gorm:"size:120"` - Content string `json:"content" gorm:"type:text"` - Level AnnouncementLevel `json:"level" gorm:"index;size:24"` - Status AnnouncementStatus `json:"status" gorm:"index;size:24;index:idx_announcements_status_published,priority:1"` - CreatedBy string `json:"createdBy" gorm:"index;size:36"` - PublishedAt time.Time `json:"publishedAt" gorm:"index:idx_announcements_status_published,priority:2"` - ClosedAt *time.Time `json:"closedAt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type UserAnnouncementRead struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_user_announcement_read,priority:1"` - AnnouncementID string `json:"announcementId" gorm:"index;size:36;uniqueIndex:idx_user_announcement_read,priority:2"` - ReadAt time.Time `json:"readAt"` -} - -type Task struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_tasks_user_created,priority:1"` - SessionID string `json:"sessionId" gorm:"index;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:80"` - Type string `json:"type" gorm:"index;size:64"` - Status TaskStatus `json:"status" gorm:"index;size:24;index:idx_tasks_status_created,priority:1;index:idx_tasks_claim,priority:1;index:idx_tasks_provider_cancel,priority:1"` - Stage string `json:"stage" gorm:"size:80"` - Progress int `json:"progress"` - Prompt string `json:"prompt"` - Operation string `json:"operation" gorm:"size:64"` - Provider string `json:"provider" gorm:"size:64"` - Model string `json:"model" gorm:"size:120"` - BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` - ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` - ProviderCancelStatus ProviderCancelStatus `json:"providerCancelStatus,omitempty" gorm:"index;size:24;index:idx_tasks_provider_cancel,priority:2"` - ProviderCancelError string `json:"providerCancelError,omitempty" gorm:"type:text"` - ProviderCancelAttempts int `json:"providerCancelAttempts,omitempty"` - ProviderCancelRequestedAt *time.Time `json:"providerCancelRequestedAt,omitempty"` - ProviderCancelledAt *time.Time `json:"providerCancelledAt,omitempty"` - ProviderCancelNextCheckAt *time.Time `json:"providerCancelNextCheckAt,omitempty" gorm:"index:idx_tasks_provider_cancel,priority:3"` - PollStage string `json:"pollStage,omitempty" gorm:"size:32"` - NextPollAt *time.Time `json:"nextPollAt,omitempty" gorm:"index"` - LeaseOwner string `json:"-" gorm:"index;size:120"` - LeaseExpiresAt *time.Time `json:"-" gorm:"index;index:idx_tasks_claim,priority:2"` - InputJSON string `json:"inputJson" gorm:"type:text"` - ResultJSON string `json:"resultJson" gorm:"type:text"` - TextDraft string `json:"textDraft,omitempty" gorm:"type:text"` - Error string `json:"error"` - Attempts int `json:"attempts"` - StartedAt *time.Time `json:"startedAt"` - CompletedAt *time.Time `json:"completedAt"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_tasks_user_created,priority:2;index:idx_tasks_status_created,priority:2;index:idx_tasks_claim,priority:3"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// TaskTextDelta 只保存可回放窗口内的文本增量;最终正文和失败草稿分别归并到 Task.ResultJSON 与 Task.TextDraft。 -type TaskTextDelta struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36;index:idx_task_text_deltas_user_created,priority:1"` - TaskID string `json:"taskId" gorm:"index;size:36;uniqueIndex:idx_task_text_deltas_sequence,priority:1"` - Sequence int64 `json:"sequence" gorm:"uniqueIndex:idx_task_text_deltas_sequence,priority:2"` - Content string `json:"content" gorm:"type:text"` - ByteCount int64 `json:"byteCount"` - CreatedAt time.Time `json:"createdAt" gorm:"index:idx_task_text_deltas_user_created,priority:2"` - ExpiresAt time.Time `json:"expiresAt" gorm:"index"` -} - -type Session struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - ProjectID string `json:"projectId" gorm:"index;size:80"` - Status SessionStatus `json:"status" gorm:"index;size:24"` - Prompt string `json:"prompt"` - CanvasSnapshotJSON string `json:"canvasSnapshotJson" gorm:"type:text"` - CanvasOpsJSON string `json:"canvasOpsJson" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} - -type Message struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - SessionID string `json:"sessionId" gorm:"index;size:36"` - Role string `json:"role" gorm:"size:24"` - Content string `json:"content"` - Payload string `json:"payload" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` -} - -type TaskLog struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - TaskID string `json:"taskId" gorm:"index;size:36"` - Level string `json:"level" gorm:"size:24"` - Message string `json:"message"` - Payload string `json:"payload" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` -} - -type SessionFile struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - SessionID string `json:"sessionId" gorm:"index;size:36"` - FileName string `json:"fileName"` - MimeType string `json:"mimeType"` - Path string `json:"-"` - Size int64 `json:"size"` - CreatedAt time.Time `json:"createdAt"` -} - -type Result struct { - ID string `json:"id" gorm:"primaryKey;size:36"` - UserID string `json:"userId" gorm:"index;size:36"` - TaskID string `json:"taskId" gorm:"index;size:36"` - SessionID string `json:"sessionId" gorm:"index;size:36"` - Kind string `json:"kind" gorm:"size:64"` - URL string `json:"url"` - Payload string `json:"payload" gorm:"type:text"` - CreatedAt time.Time `json:"createdAt"` -} diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go new file mode 100644 index 000000000..ad5f71507 --- /dev/null +++ b/backend/internal/model/models_channel.go @@ -0,0 +1,94 @@ +package model + +import ( + "gorm.io/gorm" + "time" +) + +type ModelChannel struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + Scope ChannelScope `json:"scope" gorm:"index;size:24"` + Enabled bool `json:"enabled" gorm:"index"` + Name string `json:"name" gorm:"size:80"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"-"` + SecretKey string `json:"-"` + APIFormat string `json:"apiFormat" gorm:"size:24"` + ConcurrencyLimit int `json:"concurrencyLimit"` + ModelsJSON string `json:"modelsJson" gorm:"type:text"` + HeadersJSON string `json:"-" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +type ChannelModel struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ChannelID string `json:"channelId" gorm:"size:36;index;uniqueIndex:idx_channel_model_key_active,priority:1,where:deleted_at IS NULL"` + ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"` + DisplayName string `json:"displayName" gorm:"size:160"` + Capability string `json:"capability" gorm:"size:32;index"` + Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"` + BillingMode string `json:"billingMode" gorm:"size:32"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + PriceConfigured bool `json:"priceConfigured" gorm:"index"` + Enabled bool `json:"enabled" gorm:"index"` + PriceVersion int64 `json:"priceVersion"` + CapabilityConfigJSON string `json:"-" gorm:"type:text"` + CapabilityVersion int64 `json:"capabilityVersion"` + CapabilityConfig map[string]any `json:"capabilityConfig,omitempty" gorm:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +type ApiCallLog struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_api_logs_user_created,priority:1"` + UserDisplayName string `json:"userDisplayName,omitempty" gorm:"-"` + UserAccount string `json:"userAccount,omitempty" gorm:"-"` + ChannelID string `json:"channelId" gorm:"index;size:36;index:idx_api_logs_channel_created,priority:1"` + ChannelName string `json:"channelName" gorm:"-"` + TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` + TaskStatus TaskStatus `json:"taskStatus,omitempty" gorm:"-"` + BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` + Source string `json:"source" gorm:"index;size:64"` + Capability string `json:"capability" gorm:"index;size:32"` + Operation string `json:"operation" gorm:"size:64"` + RequestKind string `json:"requestKind" gorm:"index;size:24"` + Billable bool `json:"billable" gorm:"index"` + APIFormat string `json:"apiFormat" gorm:"size:24"` + Method string `json:"method" gorm:"size:16"` + Path string `json:"path"` + Model string `json:"model" gorm:"size:120;index:idx_api_logs_model_created,priority:1"` + Status ApiCallStatus `json:"status" gorm:"index;size:24;index:idx_api_logs_status_created,priority:1"` + StatusCode int `json:"statusCode"` + DurationMs int64 `json:"durationMs"` + PollCount int `json:"pollCount"` + ProviderStatus string `json:"providerStatus,omitempty" gorm:"size:32"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedTokens int64 `json:"cachedTokens"` + UsageAvailable bool `json:"usageAvailable"` + MediaCount int `json:"mediaCount"` + MediaPreviewURL string `json:"mediaPreviewUrl,omitempty" gorm:"-"` + MediaPreviewKind string `json:"mediaPreviewKind,omitempty" gorm:"-"` + VideoSeconds int `json:"videoSeconds"` + ProviderRequestID string `json:"providerRequestId" gorm:"size:160"` + EstimatedCostMicros int64 `json:"estimatedCostMicros"` + CostAvailable bool `json:"costAvailable"` + Currency string `json:"currency" gorm:"size:12"` + ErrorCode string `json:"errorCode,omitempty" gorm:"index;size:80"` + Error string `json:"error"` + ConcurrencyLimit int `json:"concurrencyLimit"` + UpstreamURL string `json:"upstreamUrl"` + RequestContentType string `json:"requestContentType,omitempty" gorm:"size:160"` + RequestBody string `json:"requestBody,omitempty" gorm:"type:text"` + ResponseBody string `json:"responseBody,omitempty" gorm:"type:text"` + StartedAt time.Time `json:"startedAt"` + CreatedAt time.Time `json:"createdAt" gorm:"index;index:idx_api_logs_user_created,priority:2;index:idx_api_logs_channel_created,priority:2;index:idx_api_logs_model_created,priority:2;index:idx_api_logs_status_created,priority:2"` +} diff --git a/backend/internal/model/models_finance.go b/backend/internal/model/models_finance.go new file mode 100644 index 000000000..54db099c6 --- /dev/null +++ b/backend/internal/model/models_finance.go @@ -0,0 +1,116 @@ +package model + +import "time" + +type CreditAccount struct { + UserID string `json:"userId" gorm:"primaryKey;size:36"` + AvailableMicrocredits int64 `json:"availableMicrocredits"` + ReservedMicrocredits int64 `json:"reservedMicrocredits"` + Version int64 `json:"version"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CreditLedgerEntry struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;index:idx_credit_ledger_user_created,priority:1"` + Type CreditLedgerType `json:"type" gorm:"size:32;index"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + AvailableDeltaMicrocredits int64 `json:"availableDeltaMicrocredits"` + ReservedDeltaMicrocredits int64 `json:"reservedDeltaMicrocredits"` + AvailableAfterMicrocredits int64 `json:"availableAfterMicrocredits"` + ReservedAfterMicrocredits int64 `json:"reservedAfterMicrocredits"` + BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` + RedeemCodeID string `json:"redeemCodeId,omitempty" gorm:"index;size:36"` + ActorUserID string `json:"actorUserId,omitempty" gorm:"index;size:36"` + Model string `json:"model,omitempty" gorm:"size:120;index"` + ChannelID string `json:"channelId,omitempty" gorm:"size:36;index"` + Scene string `json:"scene,omitempty" gorm:"size:80;index"` + Note string `json:"note,omitempty" gorm:"size:500"` + ReferenceKey *string `json:"referenceKey,omitempty" gorm:"size:180;uniqueIndex"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_credit_ledger_user_created,priority:2"` +} + +type BillingOrder struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_billing_user_idempotency,priority:1"` + IdempotencyKey string `json:"idempotencyKey" gorm:"size:160;uniqueIndex:idx_billing_user_idempotency,priority:2"` + TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` + ChannelID string `json:"channelId" gorm:"index;size:36"` + ChannelModelID string `json:"channelModelId" gorm:"index;size:36"` + Model string `json:"model" gorm:"index;size:120"` + Capability string `json:"capability" gorm:"index;size:32"` + Scene string `json:"scene" gorm:"index;size:80"` + BillingMode string `json:"billingMode" gorm:"size:32"` + PriceVersion int64 `json:"priceVersion"` + UnitPriceMicrocredits int64 `json:"unitPriceMicrocredits"` + MultiplierBasisPoints int64 `json:"multiplierBasisPoints"` + Quantity int64 `json:"quantity"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + ReservedAmountMicrocredits int64 `json:"reservedAmountMicrocredits"` + ActualAmountMicrocredits int64 `json:"actualAmountMicrocredits"` + RefundedAmountMicrocredits int64 `json:"refundedAmountMicrocredits"` + InputTokenPriceMicrocredits int64 `json:"inputTokenPriceMicrocredits"` + OutputTokenPriceMicrocredits int64 `json:"outputTokenPriceMicrocredits"` + CachedTokenPriceMicrocredits int64 `json:"cachedTokenPriceMicrocredits"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` + CachedTokens int64 `json:"cachedTokens"` + UsageAvailable bool `json:"usageAvailable"` + Status BillingStatus `json:"status" gorm:"index;size:24"` + ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` + Error string `json:"error,omitempty" gorm:"size:1000"` + ResolvedBy string `json:"resolvedBy,omitempty" gorm:"index;size:36"` + ResolutionNote string `json:"resolutionNote,omitempty" gorm:"size:500"` + StartedAt *time.Time `json:"startedAt"` + SettledAt *time.Time `json:"settledAt"` + RefundedAt *time.Time `json:"refundedAt"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type RedeemBatch struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + Count int `json:"count"` + Note string `json:"note" gorm:"size:500"` + CreatedBy string `json:"createdBy" gorm:"index;size:36"` + CodesCipher string `json:"-" gorm:"type:text"` + ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + AvailableCount int64 `json:"availableCount" gorm:"->;-:migration"` + RedeemedCount int64 `json:"redeemedCount" gorm:"->;-:migration"` + DisabledCount int64 `json:"disabledCount" gorm:"->;-:migration"` + ExpiredCount int64 `json:"expiredCount" gorm:"->;-:migration"` +} + +type RedeemCode struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + BatchID string `json:"batchId" gorm:"index;size:36;index:idx_redeem_codes_batch_status,priority:1;index:idx_redeem_codes_batch_created,priority:1"` + CodeHash string `json:"-" gorm:"uniqueIndex;size:64"` + CodeSuffix string `json:"codeSuffix" gorm:"size:4"` + AmountMicrocredits int64 `json:"amountMicrocredits"` + Status RedeemCodeStatus `json:"status" gorm:"index;size:24;index:idx_redeem_codes_batch_status,priority:2"` + RedeemedBy string `json:"redeemedBy,omitempty" gorm:"index;size:36"` + RedeemedAt *time.Time `json:"redeemedAt"` + RedeemedIP string `json:"redeemedIp,omitempty" gorm:"size:64"` + ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_redeem_codes_batch_created,priority:2"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ModelPricing struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ChannelID string `json:"channelId" gorm:"size:36;uniqueIndex:idx_model_pricing_scope,priority:1"` + Model string `json:"model" gorm:"size:120;uniqueIndex:idx_model_pricing_scope,priority:2"` + Capability string `json:"capability" gorm:"size:32;uniqueIndex:idx_model_pricing_scope,priority:3"` + Currency string `json:"currency" gorm:"size:12"` + InputPerMillionMicros int64 `json:"inputPerMillionMicros"` + OutputPerMillionMicros int64 `json:"outputPerMillionMicros"` + CachedPerMillionMicros int64 `json:"cachedPerMillionMicros"` + PerRequestMicros int64 `json:"perRequestMicros"` + PerMediaMicros int64 `json:"perMediaMicros"` + PerVideoSecondMicros int64 `json:"perVideoSecondMicros"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/model/models_identity.go b/backend/internal/model/models_identity.go new file mode 100644 index 000000000..249f80695 --- /dev/null +++ b/backend/internal/model/models_identity.go @@ -0,0 +1,57 @@ +package model + +import "time" + +type User struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Username string `json:"username" gorm:"uniqueIndex;size:80"` + Email string `json:"email,omitempty" gorm:"size:160"` + DisplayName string `json:"displayName" gorm:"size:80"` + Role UserRole `json:"role" gorm:"index;size:24"` + Status UserStatus `json:"status" gorm:"index;size:24"` + PasswordHash string `json:"-"` + LastLoginAt *time.Time `json:"lastLoginAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AuthSession struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + TokenHash string `json:"-"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserIdentity struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + Provider string `json:"provider" gorm:"size:32;uniqueIndex:idx_user_identity_provider_subject,priority:1"` + Subject string `json:"subject" gorm:"size:160;uniqueIndex:idx_user_identity_provider_subject,priority:2"` + ProviderUsername string `json:"providerUsername" gorm:"size:160"` + AvatarURL string `json:"avatarUrl"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type OAuthState struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Provider string `json:"provider" gorm:"index;size:32"` + StateHash string `json:"-" gorm:"uniqueIndex;size:64"` + CodeVerifier string `json:"-" gorm:"size:160"` + NextPath string `json:"nextPath"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index"` + UsedAt *time.Time `json:"usedAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` +} + +type EmailVerificationCode struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Email string `json:"email" gorm:"index;size:160"` + CodeHash string `json:"-" gorm:"size:64"` + Purpose string `json:"purpose" gorm:"index;size:32"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index"` + UsedAt *time.Time `json:"usedAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` +} diff --git a/backend/internal/model/models_platform.go b/backend/internal/model/models_platform.go new file mode 100644 index 000000000..858b83090 --- /dev/null +++ b/backend/internal/model/models_platform.go @@ -0,0 +1,77 @@ +package model + +import "time" + +type UserDailyActivity struct { + ID string `json:"id" gorm:"primaryKey;size:64"` + Day time.Time `json:"day" gorm:"type:date;uniqueIndex:idx_user_daily_activity_day_user,priority:1;index"` + UserID string `json:"userId" gorm:"size:36;uniqueIndex:idx_user_daily_activity_day_user,priority:2;index"` + FirstActiveAt *time.Time `json:"firstActiveAt"` + LastActiveAt *time.Time `json:"lastActiveAt"` + LoginCount int `json:"loginCount"` + TaskCount int `json:"taskCount"` + AgentMessageCount int `json:"agentMessageCount"` + CanvasActive bool `json:"canvasActive"` + AssetCount int `json:"assetCount"` + ResourceCount int `json:"resourceCount"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type SystemSetting struct { + Key string `json:"key" gorm:"primaryKey;size:80"` + ValueJSON string `json:"valueJson" gorm:"type:text"` + UpdatedBy string `json:"updatedBy" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserOSSSetting struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_user_oss_settings_user_created,priority:1"` + Enabled bool `json:"enabled" gorm:"index"` + ValueJSON string `json:"-" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_user_oss_settings_user_created,priority:2"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserDailyUploadUsage struct { + ID string `json:"id" gorm:"primaryKey;size:64"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_user_daily_upload_day,priority:1"` + Day string `json:"day" gorm:"size:10;index;uniqueIndex:idx_user_daily_upload_day,priority:2"` + Bytes int64 `json:"bytes"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Skill struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + OwnerID string `json:"ownerId" gorm:"index;size:36"` + AuthorName string `json:"authorName" gorm:"size:120;index"` + AuthorAvatarURL string `json:"authorAvatarUrl" gorm:"size:1000"` + Name string `json:"name" gorm:"size:80;index"` + Description string `json:"description" gorm:"size:500"` + Instruction string `json:"instruction" gorm:"type:text"` + Status int `json:"status" gorm:"index"` + Source int `json:"source" gorm:"index"` + Tag string `json:"tag" gorm:"size:32;index"` + SortWeight int `json:"sortWeight" gorm:"index"` + IsPrivate bool `json:"isPrivate" gorm:"index"` + MarkdownURL string `json:"markdownUrl" gorm:"size:500"` + ShowcaseMediaJSON string `json:"-" gorm:"type:text"` + ExtraInfo string `json:"extraInfo" gorm:"type:text"` + InitialLikeCount int64 `json:"initialLikeCount"` + InitialAddedCount int64 `json:"initialAddedCount"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index"` +} + +type UserSkillState struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_user_skill_state_user_skill,priority:1"` + SkillID string `json:"skillId" gorm:"size:36;index;uniqueIndex:idx_user_skill_state_user_skill,priority:2"` + Added bool `json:"added" gorm:"index"` + Liked bool `json:"liked" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/model/models_project.go b/backend/internal/model/models_project.go new file mode 100644 index 000000000..7a5818edd --- /dev/null +++ b/backend/internal/model/models_project.go @@ -0,0 +1,279 @@ +package model + +import "time" + +type Resource struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_resources_user_created,priority:1"` + Kind string `json:"kind" gorm:"index;size:24"` + Status ResourceStatus `json:"status" gorm:"index;size:24"` + Provider string `json:"provider" gorm:"size:24"` + Endpoint string `json:"endpoint"` + Bucket string `json:"bucket" gorm:"size:160"` + // 用户 OSS 每次修改都会生成新版本,资源固定引用创建时的版本,避免历史资源因换密钥失效。 + StorageSettingID string `json:"-" gorm:"index;size:36"` + ObjectKey string `json:"objectKey" gorm:"index"` + PublicURL string `json:"publicUrl"` + MimeType string `json:"mimeType" gorm:"size:120"` + Size int64 `json:"size"` + Width int `json:"width"` + Height int `json:"height"` + DurationMs int64 `json:"durationMs"` + ETag string `json:"etag" gorm:"size:160"` + Error string `json:"error"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_resources_user_created,priority:2"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Asset struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_assets_user_updated,priority:1"` + Kind string `json:"kind" gorm:"index;size:24"` + Category AssetCategory `json:"category" gorm:"index;size:32"` + Status AssetVersionStatus `json:"status" gorm:"index;size:24"` + PrimaryVersionID string `json:"primaryVersionId,omitempty" gorm:"index;size:36"` + Title string `json:"title" gorm:"size:240"` + PayloadJSON string `json:"payloadJson" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_assets_user_updated,priority:2"` +} + +type ProjectAssetLink struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:1"` + AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_project_asset_links_unique,priority:2"` + CreatedAt time.Time `json:"createdAt"` +} + +type ProjectAssetCandidate struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36"` + UnitID string `json:"unitId,omitempty" gorm:"index;size:36"` + ShotID string `json:"shotId,omitempty" gorm:"index;size:36"` + Name string `json:"name" gorm:"size:240"` + Category AssetCategory `json:"category" gorm:"index;size:32"` + Status string `json:"status" gorm:"index;size:32"` + DetailsJSON string `json:"detailsJson" gorm:"type:text"` + ResolvedAssetID string `json:"resolvedAssetId,omitempty" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AssetVersion struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + AssetID string `json:"assetId" gorm:"index;size:36;uniqueIndex:idx_asset_versions_number,priority:1"` + Version int `json:"version" gorm:"uniqueIndex:idx_asset_versions_number,priority:2"` + Status AssetVersionStatus `json:"status" gorm:"index;size:24"` + DefinitionJSON string `json:"definitionJson" gorm:"type:text"` + Prompt string `json:"prompt" gorm:"type:text"` + Note string `json:"note" gorm:"size:500"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type AssetRepresentation struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + TaskID string `json:"taskId,omitempty" gorm:"index;size:36;uniqueIndex:idx_asset_representations_task_role,priority:1"` + AssetVersionID string `json:"assetVersionId" gorm:"index;size:36;uniqueIndex:idx_asset_representations_version_role,priority:1"` + ResourceID string `json:"resourceId,omitempty" gorm:"index;size:36"` + MediaType string `json:"mediaType" gorm:"index;size:24"` + Role string `json:"role" gorm:"index;size:32;uniqueIndex:idx_asset_representations_task_role,priority:2;uniqueIndex:idx_asset_representations_version_role,priority:2"` + MetadataJSON string `json:"metadataJson" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} + +// VoiceProfile 是可复用的声音身份;试听音频只是表现资源,不等同于声音本身。 +type VoiceProfile struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_voice_profiles_user_provider_key,priority:1"` + Name string `json:"name" gorm:"size:160"` + Provider string `json:"provider" gorm:"size:48;uniqueIndex:idx_voice_profiles_user_provider_key,priority:2"` + VoiceKey string `json:"voiceKey" gorm:"size:160;uniqueIndex:idx_voice_profiles_user_provider_key,priority:3"` + Language string `json:"language" gorm:"size:80"` + Timbre string `json:"timbre" gorm:"size:240"` + SampleResourceID string `json:"sampleResourceId,omitempty" gorm:"index;size:36"` + CompatibleModelsJSON string `json:"compatibleModelsJson" gorm:"type:text"` + Status string `json:"status" gorm:"index;size:24"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CharacterVoiceBinding struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + AssetVersionID string `json:"assetVersionId" gorm:"uniqueIndex;size:36"` + VoiceProfileID string `json:"voiceProfileId" gorm:"index;size:36"` + Instructions string `json:"instructions" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// Project 是短剧领域聚合根;CanvasProject 仍代表可独立创作的画布文档。 +type Project struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_projects_user_name,priority:1"` + Name string `json:"name" gorm:"size:240;uniqueIndex:idx_projects_user_name,priority:2"` + Type string `json:"type" gorm:"size:32;index"` + AspectRatio string `json:"aspectRatio" gorm:"size:16"` + SourceType string `json:"sourceType" gorm:"size:32"` + Description string `json:"description" gorm:"type:text"` + StylePresetID string `json:"stylePresetId" gorm:"size:64"` + Status ProjectStatus `json:"status" gorm:"index;size:24"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index"` +} + +type ProjectUnit struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36"` + ParentID string `json:"parentId,omitempty" gorm:"index;size:36"` + Kind ProjectUnitKind `json:"kind" gorm:"index;size:24"` + Title string `json:"title" gorm:"size:240"` + SourceText string `json:"sourceText" gorm:"type:text"` + Status ProjectUnitStatus `json:"status" gorm:"index;size:24"` + Position int `json:"position"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type CanvasUnitLink struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:1"` + CanvasID string `json:"canvasId" gorm:"index;size:80;uniqueIndex:idx_canvas_unit_links_unique,priority:2"` + UnitID string `json:"unitId" gorm:"index;size:36;uniqueIndex:idx_canvas_unit_links_unique,priority:3"` + Role string `json:"role" gorm:"size:32"` + CreatedAt time.Time `json:"createdAt"` +} + +type Shot struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36"` + UnitID string `json:"unitId" gorm:"index;size:36"` + Title string `json:"title" gorm:"size:240"` + Description string `json:"description" gorm:"type:text"` + Position int `json:"position"` + DurationMs int64 `json:"durationMs"` + Status string `json:"status" gorm:"index;size:24"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type ShotAssetReference struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ShotID string `json:"shotId" gorm:"index;size:36;uniqueIndex:idx_shot_asset_reference_unique,priority:1"` + AssetVersionID string `json:"assetVersionId" gorm:"index;size:36;uniqueIndex:idx_shot_asset_reference_unique,priority:2"` + Role string `json:"role" gorm:"index;size:32;uniqueIndex:idx_shot_asset_reference_unique,priority:3"` + Status string `json:"status" gorm:"index;size:24"` + CreatedAt time.Time `json:"createdAt"` +} + +type WorkflowTemplateVersion struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + TemplateKey string `json:"templateKey" gorm:"size:80;uniqueIndex:idx_workflow_template_version,priority:1"` + Name string `json:"name" gorm:"size:160"` + Version int `json:"version" gorm:"uniqueIndex:idx_workflow_template_version,priority:2"` + DefinitionJSON string `json:"definitionJson" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} + +type WorkflowInstance struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:1"` + UnitID string `json:"unitId,omitempty" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:2"` + TemplateVersionID string `json:"templateVersionId" gorm:"index;size:36;uniqueIndex:idx_workflow_instance_scope,priority:3"` + Scope string `json:"scope" gorm:"index;size:24"` + Status WorkflowStatus `json:"status" gorm:"index;size:24"` + Revision int64 `json:"revision"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type WorkflowStepInstance struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + WorkflowInstanceID string `json:"workflowInstanceId" gorm:"index;size:36;uniqueIndex:idx_workflow_steps_instance_key,priority:1"` + StepKey string `json:"stepKey" gorm:"size:80;uniqueIndex:idx_workflow_steps_instance_key,priority:2"` + Name string `json:"name" gorm:"size:160"` + Position int `json:"position"` + Status WorkflowStepStatus `json:"status" gorm:"index;size:24"` + InputJSON string `json:"inputJson" gorm:"type:text"` + OutputJSON string `json:"outputJson" gorm:"type:text"` + Error string `json:"error" gorm:"type:text"` + StartedAt *time.Time `json:"startedAt"` + CompletedAt *time.Time `json:"completedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type WorkflowStepTask struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + WorkflowStepID string `json:"workflowStepId" gorm:"index;size:36;uniqueIndex:idx_workflow_step_tasks_unique,priority:1"` + TaskID string `json:"taskId" gorm:"index;size:36;uniqueIndex:idx_workflow_step_tasks_unique,priority:2"` + CreatedAt time.Time `json:"createdAt"` +} + +type CanvasProject struct { + ID string `json:"id" gorm:"primaryKey;size:80"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_canvas_projects_user_updated,priority:1"` + ProjectID string `json:"projectId,omitempty" gorm:"index;size:36"` + Title string `json:"title" gorm:"size:240"` + PayloadJSON string `json:"payloadJson" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt" gorm:"index:idx_canvas_projects_user_updated,priority:2"` +} + +type CanvasShare struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_canvas_share_owner_project,priority:1"` + ProjectID string `json:"projectId" gorm:"index;size:80;uniqueIndex:idx_canvas_share_owner_project,priority:2"` + TokenHash string `json:"-" gorm:"uniqueIndex;size:64"` + TokenCipher string `json:"-" gorm:"type:text"` + Enabled bool `json:"enabled" gorm:"index"` + ExpiresAt *time.Time `json:"expiresAt" gorm:"index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PromptTemplate struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Operation string `json:"operation" gorm:"size:64;index;uniqueIndex:idx_prompt_template_operation_version,priority:1"` + Name string `json:"name" gorm:"size:120"` + Version int `json:"version" gorm:"uniqueIndex:idx_prompt_template_operation_version,priority:2"` + Content string `json:"content" gorm:"type:text"` + OutputType string `json:"outputType" gorm:"size:24"` + Enabled bool `json:"enabled" gorm:"index;index:idx_prompt_template_active,priority:2"` + CreatedBy string `json:"createdBy" gorm:"index;size:36"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// UserPromptCustomization 只保存用户的创作策略层,动态上下文和输出契约始终由服务端编译器注入。 +type UserPromptCustomization struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"size:36;index;uniqueIndex:idx_user_prompt_operation,priority:1"` + Operation string `json:"operation" gorm:"size:64;index;uniqueIndex:idx_user_prompt_operation,priority:2"` + Mode string `json:"mode" gorm:"size:24"` + Content string `json:"content" gorm:"type:text"` + BaseTemplateID string `json:"baseTemplateId" gorm:"size:36;index"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Announcement struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + Title string `json:"title" gorm:"size:120"` + Content string `json:"content" gorm:"type:text"` + Level AnnouncementLevel `json:"level" gorm:"index;size:24"` + Status AnnouncementStatus `json:"status" gorm:"index;size:24;index:idx_announcements_status_published,priority:1"` + CreatedBy string `json:"createdBy" gorm:"index;size:36"` + PublishedAt time.Time `json:"publishedAt" gorm:"index:idx_announcements_status_published,priority:2"` + ClosedAt *time.Time `json:"closedAt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type UserAnnouncementRead struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;uniqueIndex:idx_user_announcement_read,priority:1"` + AnnouncementID string `json:"announcementId" gorm:"index;size:36;uniqueIndex:idx_user_announcement_read,priority:2"` + ReadAt time.Time `json:"readAt"` +} diff --git a/backend/internal/model/models_task.go b/backend/internal/model/models_task.go new file mode 100644 index 000000000..22d5d9522 --- /dev/null +++ b/backend/internal/model/models_task.go @@ -0,0 +1,105 @@ +package model + +import "time" + +type Task struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_tasks_user_created,priority:1"` + SessionID string `json:"sessionId" gorm:"index;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:80"` + Type string `json:"type" gorm:"index;size:64"` + Status TaskStatus `json:"status" gorm:"index;size:24;index:idx_tasks_status_created,priority:1;index:idx_tasks_claim,priority:1;index:idx_tasks_provider_cancel,priority:1"` + Stage string `json:"stage" gorm:"size:80"` + Progress int `json:"progress"` + Prompt string `json:"prompt"` + Operation string `json:"operation" gorm:"size:64"` + Provider string `json:"provider" gorm:"size:64"` + Model string `json:"model" gorm:"size:120"` + BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` + ProviderRequestID string `json:"providerRequestId,omitempty" gorm:"index;size:160"` + ProviderCancelStatus ProviderCancelStatus `json:"providerCancelStatus,omitempty" gorm:"index;size:24;index:idx_tasks_provider_cancel,priority:2"` + ProviderCancelError string `json:"providerCancelError,omitempty" gorm:"type:text"` + ProviderCancelAttempts int `json:"providerCancelAttempts,omitempty"` + ProviderCancelRequestedAt *time.Time `json:"providerCancelRequestedAt,omitempty"` + ProviderCancelledAt *time.Time `json:"providerCancelledAt,omitempty"` + ProviderCancelNextCheckAt *time.Time `json:"providerCancelNextCheckAt,omitempty" gorm:"index:idx_tasks_provider_cancel,priority:3"` + PollStage string `json:"pollStage,omitempty" gorm:"size:32"` + NextPollAt *time.Time `json:"nextPollAt,omitempty" gorm:"index"` + LeaseOwner string `json:"-" gorm:"index;size:120"` + LeaseExpiresAt *time.Time `json:"-" gorm:"index;index:idx_tasks_claim,priority:2"` + InputJSON string `json:"inputJson" gorm:"type:text"` + ResultJSON string `json:"resultJson" gorm:"type:text"` + TextDraft string `json:"textDraft,omitempty" gorm:"type:text"` + Error string `json:"error"` + Attempts int `json:"attempts"` + StartedAt *time.Time `json:"startedAt"` + CompletedAt *time.Time `json:"completedAt"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_tasks_user_created,priority:2;index:idx_tasks_status_created,priority:2;index:idx_tasks_claim,priority:3"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// TaskTextDelta 只保存可回放窗口内的文本增量;最终正文和失败草稿分别归并到 Task.ResultJSON 与 Task.TextDraft。 +type TaskTextDelta struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36;index:idx_task_text_deltas_user_created,priority:1"` + TaskID string `json:"taskId" gorm:"index;size:36;uniqueIndex:idx_task_text_deltas_sequence,priority:1"` + Sequence int64 `json:"sequence" gorm:"uniqueIndex:idx_task_text_deltas_sequence,priority:2"` + Content string `json:"content" gorm:"type:text"` + ByteCount int64 `json:"byteCount"` + CreatedAt time.Time `json:"createdAt" gorm:"index:idx_task_text_deltas_user_created,priority:2"` + ExpiresAt time.Time `json:"expiresAt" gorm:"index"` +} + +type Session struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + ProjectID string `json:"projectId" gorm:"index;size:80"` + Status SessionStatus `json:"status" gorm:"index;size:24"` + Prompt string `json:"prompt"` + CanvasSnapshotJSON string `json:"canvasSnapshotJson" gorm:"type:text"` + CanvasOpsJSON string `json:"canvasOpsJson" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Message struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + SessionID string `json:"sessionId" gorm:"index;size:36"` + Role string `json:"role" gorm:"size:24"` + Content string `json:"content"` + Payload string `json:"payload" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} + +type TaskLog struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + TaskID string `json:"taskId" gorm:"index;size:36"` + Level string `json:"level" gorm:"size:24"` + Message string `json:"message"` + Payload string `json:"payload" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} + +type SessionFile struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + SessionID string `json:"sessionId" gorm:"index;size:36"` + FileName string `json:"fileName"` + MimeType string `json:"mimeType"` + Path string `json:"-"` + Size int64 `json:"size"` + CreatedAt time.Time `json:"createdAt"` +} + +type Result struct { + ID string `json:"id" gorm:"primaryKey;size:36"` + UserID string `json:"userId" gorm:"index;size:36"` + TaskID string `json:"taskId" gorm:"index;size:36"` + SessionID string `json:"sessionId" gorm:"index;size:36"` + Kind string `json:"kind" gorm:"size:64"` + URL string `json:"url"` + Payload string `json:"payload" gorm:"type:text"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/backend/internal/repository/billing_review.go b/backend/internal/repository/billing_review.go new file mode 100644 index 000000000..14887107c --- /dev/null +++ b/backend/internal/repository/billing_review.go @@ -0,0 +1,68 @@ +package repository + +import ( + "time" + + "infinite-canvas/backend/internal/model" +) + +// BillingReviewStats 记录需要人工关注的长期未闭合订单数量。 +// 巡检只读数据库,不改变订单状态或积分余额。 +type BillingReviewStats struct { + Reserved int64 `json:"reserved"` + Running int64 `json:"running"` + Uncertain int64 `json:"uncertain"` + Oldest *time.Time `json:"oldest,omitempty"` +} + +func (s BillingReviewStats) Total() int64 { + return s.Reserved + s.Running + s.Uncertain +} + +// StaleBillingReviewStats 查询超过 age 未更新、仍处于未闭合状态的订单。 +func (r *Repository) StaleBillingReviewStats(now time.Time, age time.Duration) (BillingReviewStats, error) { + cutoff := now.Add(-age) + stats := BillingReviewStats{} + var rows []struct { + Status model.BillingStatus + Count int64 + } + if err := r.db.Model(&model.BillingOrder{}). + Select("status, count(*) AS count"). + Where("status IN ? AND updated_at < ?", []model.BillingStatus{ + model.BillingStatusReserved, + model.BillingStatusRunning, + model.BillingStatusUncertain, + }, cutoff). + Group("status").Scan(&rows).Error; err != nil { + return stats, err + } + for _, row := range rows { + switch row.Status { + case model.BillingStatusReserved: + stats.Reserved = row.Count + case model.BillingStatusRunning: + stats.Running = row.Count + case model.BillingStatusUncertain: + stats.Uncertain = row.Count + } + } + if stats.Total() == 0 { + return stats, nil + } + var oldest struct { + CreatedAt time.Time + } + if err := r.db.Model(&model.BillingOrder{}). + Select("created_at"). + Where("status IN ? AND updated_at < ?", []model.BillingStatus{ + model.BillingStatusReserved, + model.BillingStatusRunning, + model.BillingStatusUncertain, + }, cutoff). + Order("created_at asc").Limit(1).Scan(&oldest).Error; err != nil { + return stats, err + } + stats.Oldest = &oldest.CreatedAt + return stats, nil +} diff --git a/backend/internal/service/billing_review.go b/backend/internal/service/billing_review.go new file mode 100644 index 000000000..81d994007 --- /dev/null +++ b/backend/internal/service/billing_review.go @@ -0,0 +1,44 @@ +package service + +import ( + "log" + "time" + + "infinite-canvas/backend/internal/repository" +) + +const billingReviewStaleAfter = 15 * time.Minute + +// AuditBillingReview 执行只读账单巡检,提醒运维及时进入人工核对链路。 +// 资金状态必须由人工依据上游证据结算或退款,巡检不能代替资金动作。 +func (s *Service) AuditBillingReview() error { + stats, err := s.repo.StaleBillingReviewStats(time.Now(), billingReviewStaleAfter) + if err != nil { + return err + } + if stats.Total() == 0 { + return nil + } + oldest := "" + if stats.Oldest != nil { + oldest = stats.Oldest.UTC().Format(time.RFC3339) + } + log.Printf("billing review audit stale_orders=%d reserved=%d running=%d uncertain=%d oldest=%s", stats.Total(), stats.Reserved, stats.Running, stats.Uncertain, oldest) + return nil +} + +func (s *Service) startBillingReviewAudit() { + audit := func() { + if err := s.AuditBillingReview(); err != nil { + log.Printf("billing review audit failed: %v", err) + } + } + audit() + go func() { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for range ticker.C { + audit() + } + }() +} diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 84d1766fa..b308e63f6 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -1022,7 +1022,13 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string] id := resumedProviderRequestID(ctx) var created map[string]interface{} if id == "" && (input.Config.InterfaceType == "xai-video" || isGrokVideoConfig(input.Config)) { - requestBody, err := grokVideoBody(input) + var requestBody interface{} + var err error + if input.Config.InterfaceType == "xai-video" { + requestBody, err = xaiVideoRequestBody(input) + } else { + requestBody, err = grokVideoBody(input) + } if err != nil { return nil, err } @@ -1109,21 +1115,21 @@ func runGeminiVeoVideoTask(ctx context.Context, input canvasGenerationInput) (ma } id := resumedProviderRequestID(ctx) if id == "" { - instance := map[string]interface{}{"prompt": strings.TrimSpace(input.Prompt)} + instance := geminiVeoInstance{Prompt: strings.TrimSpace(input.Prompt)} if len(input.ReferenceImages) == 1 { raw, mimeType, err := mediaBytes(input.ReferenceImages[0]) if err != nil { return nil, fmt.Errorf("读取 Gemini Veo 起始图失败:%w", err) } - instance["image"] = map[string]interface{}{"bytesBase64Encoded": base64.StdEncoding.EncodeToString(raw), "mimeType": mimeType} + instance.Image = &geminiVeoImage{BytesBase64Encoded: base64.StdEncoding.EncodeToString(raw), MIMEType: mimeType} } - body := map[string]interface{}{ - "instances": []interface{}{instance}, - "parameters": map[string]interface{}{ - "aspectRatio": normalizeNewAPIChannel2Ratio(input.Config.Size, strings.ToLower(input.Config.Model)), - "durationSeconds": normalizeSeedanceVideosDuration(input.Config.VideoSeconds), - "resolution": normalizeNewAPIChannel2Resolution(input.Config.VQuality, strings.ToLower(input.Config.Model)), - "sampleCount": 1, + body := geminiVeoRequest{ + Instances: []geminiVeoInstance{instance}, + Parameters: geminiVeoParameters{ + AspectRatio: normalizeNewAPIChannel2Ratio(input.Config.Size, strings.ToLower(input.Config.Model)), + DurationSeconds: normalizeSeedanceVideosDuration(input.Config.VideoSeconds), + Resolution: normalizeNewAPIChannel2Resolution(input.Config.VQuality, strings.ToLower(input.Config.Model)), + SampleCount: 1, }, } var created map[string]interface{} @@ -1273,7 +1279,7 @@ func runNewAPIChannel2VideoTask(ctx context.Context, input canvasGenerationInput id := resumedProviderRequestID(ctx) var created map[string]interface{} if id == "" { - body, err := newAPIChannel2VideoBody(input) + body, err := newAPIChannel2VideoRequestBody(input) if err != nil { return nil, err } @@ -1393,9 +1399,9 @@ func logNewAPIChannel2QueryRetry(ctx context.Context, providerTaskID string, ret _ = metadata.Service.log(metadata.UserID, metadata.TaskID, "warn", "上游任务查询失败,1 分钟后重试", payload) } -func newAPIChannel2VideoBody(input canvasGenerationInput) (map[string]interface{}, error) { +func newAPIChannel2VideoRequestBody(input canvasGenerationInput) (newAPIVideoRequest, error) { if len(input.ReferenceImages) > 9 || len(input.ReferenceVideos) > 3 || len(input.ReferenceAudios) > 3 { - return nil, errors.New("NewAPI Video Generations 最多支持 9 张参考图、3 个参考视频和 3 个参考音频") + return newAPIVideoRequest{}, errors.New("NewAPI Video Generations 最多支持 9 张参考图、3 个参考视频和 3 个参考音频") } modelName := strings.ToLower(strings.TrimSpace(input.Config.Model)) requiresSingleImage := modelName == "grok-video-1.5" || modelName == "grok-video-1.5-1080p" @@ -1405,19 +1411,19 @@ func newAPIChannel2VideoBody(input canvasGenerationInput) (map[string]interface{ for _, image := range input.ReferenceImages { url, err := videoGenerationsMediaURL(image) if err != nil { - return nil, err + return newAPIVideoRequest{}, err } images = append(images, url) } } if requiresSingleImage { if len(images) != 1 { - return nil, fmt.Errorf("NewAPI Video Generations 的 %s 必须且只能提供 1 张参考图(当前 %d 张)", input.Config.Model, len(images)) + return newAPIVideoRequest{}, fmt.Errorf("NewAPI Video Generations 的 %s 必须且只能提供 1 张参考图(当前 %d 张)", input.Config.Model, len(images)) } } frameImages, err := videoFrameImageURLs(input, images) if err != nil { - return nil, err + return newAPIVideoRequest{}, err } if len(frameImages) > 0 { images = frameImages @@ -1429,44 +1435,54 @@ func newAPIChannel2VideoBody(input canvasGenerationInput) (map[string]interface{ } ratio := normalizeNewAPIChannel2Ratio(input.Config.Size, modelName) resolution := normalizeNewAPIChannel2Resolution(input.Config.VQuality, modelName) - body := map[string]interface{}{ - "model": input.Config.Model, - "prompt": strings.TrimSpace(input.Prompt), - "seconds": strconv.Itoa(seconds), - "aspect_ratio": ratio, - "resolution": resolution, + body := newAPIVideoRequest{ + Model: input.Config.Model, + Prompt: strings.TrimSpace(input.Prompt), + Seconds: strconv.Itoa(seconds), + AspectRatio: ratio, + Resolution: resolution, } if videoCapabilitySupportsAudio(input) { - body["generate_audio"] = parseBool(input.Config.VideoGenerateAudio, true) + value := parseBool(input.Config.VideoGenerateAudio, true) + body.GenerateAudio = &value } if len(images) > 0 { - body["image_urls"] = images + body.ImageURLs = images } videoURLs := make([]string, 0, len(input.ReferenceVideos)) for _, video := range input.ReferenceVideos { url, err := videoGenerationsMediaURL(video) if err != nil { - return nil, err + return newAPIVideoRequest{}, err } videoURLs = append(videoURLs, url) } if len(videoURLs) > 0 { - body["video_urls"] = videoURLs + body.VideoURLs = videoURLs } audioURLs := make([]string, 0, len(input.ReferenceAudios)) for _, audio := range input.ReferenceAudios { url, err := videoGenerationsMediaURL(audio) if err != nil { - return nil, err + return newAPIVideoRequest{}, err } audioURLs = append(audioURLs, url) } if len(audioURLs) > 0 { - body["audio_urls"] = audioURLs + body.AudioURLs = audioURLs } return body, nil } +// 兼容现有单元测试和旧调用方;实际请求路径使用上面的类型化 DTO。 +func newAPIChannel2VideoBody(input canvasGenerationInput) (map[string]interface{}, error) { + body, err := newAPIChannel2VideoRequestBody(input) + if err != nil { + return nil, err + } + return requestAsMap(body) +} + func videoGenerationsMediaURL(media providerMedia) (string, error) { value := strings.TrimSpace(firstNonEmpty(media.URL, media.DataURL)) if isPublicMediaURL(value) || strings.HasPrefix(value, "data:") { @@ -1680,7 +1696,11 @@ func validateGenerationInterface(mode string, interfaceType string) error { func grokVideoBody(input canvasGenerationInput) (map[string]interface{}, error) { if input.Config.InterfaceType == "xai-video" { - return xaiVideoBody(input) + body, err := xaiVideoRequestBody(input) + if err != nil { + return nil, err + } + return requestAsMap(body) } seconds := defaultString(input.Config.VideoSeconds, "6") @@ -1713,33 +1733,42 @@ func grokVideoBody(input canvasGenerationInput) (map[string]interface{}, error) } // xAI 生成接口与 legacy /videos 使用不同字段,保持独立可避免兼容字段触发上游 422。 -func xaiVideoBody(input canvasGenerationInput) (map[string]interface{}, error) { - body := map[string]interface{}{ - "model": input.Config.Model, - "prompt": strings.TrimSpace(input.Prompt), - "duration": normalizeXAIVideoDuration(input.Config.VideoSeconds), - "aspect_ratio": normalizeXAIVideoAspectRatio(input.Config.Size), - "resolution": normalizeXAIVideoResolution(input.Config.VQuality), +func xaiVideoRequestBody(input canvasGenerationInput) (xaiVideoRequest, error) { + body := xaiVideoRequest{ + Model: input.Config.Model, + Prompt: strings.TrimSpace(input.Prompt), + Duration: normalizeXAIVideoDuration(input.Config.VideoSeconds), + AspectRatio: normalizeXAIVideoAspectRatio(input.Config.Size), + Resolution: normalizeXAIVideoResolution(input.Config.VQuality), } if !shouldSendNewAPIVideoImages(input) || len(input.ReferenceImages) == 0 { return body, nil } if len(input.ReferenceImages) > 1 { - return nil, fmt.Errorf("xAI 图生视频只支持 1 张起始图,当前连接了 %d 张", len(input.ReferenceImages)) + return xaiVideoRequest{}, fmt.Errorf("xAI 图生视频只支持 1 张起始图,当前连接了 %d 张", len(input.ReferenceImages)) } imageURL, err := openAIImageInputURL(input.ReferenceImages[0]) + if err != nil { + return xaiVideoRequest{}, err + } + body.Image = &xaiVideoImage{URL: imageURL} + return body, nil +} + +// 兼容旧的 map 断言调用;xAI 实际请求使用类型化 DTO。 +func xaiVideoBody(input canvasGenerationInput) (map[string]interface{}, error) { + body, err := xaiVideoRequestBody(input) if err != nil { return nil, err } - body["image"] = map[string]interface{}{"url": imageURL} - return body, nil + return requestAsMap(body) } func runSeedanceVideosTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { id := resumedProviderRequestID(ctx) var created map[string]interface{} if id == "" { - body, err := seedanceVideosBody(input) + body, err := seedanceVideosRequestBody(input) if err != nil { return nil, err } @@ -1807,18 +1836,20 @@ func runSeedanceAgentPlanVideoTask(ctx context.Context, input canvasGenerationIn } } } - body := map[string]interface{}{ - "model": input.Config.Model, - "content": content, - "ratio": normalizeSeedanceRatio(input.Config.Size), - "resolution": normalizeSeedanceResolution(input.Config.VQuality, input.Config.Model), - "duration": normalizeSeedanceDuration(input.Config.VideoSeconds), + body := seedanceAgentPlanRequest{ + Model: input.Config.Model, + Content: content, + Ratio: normalizeSeedanceRatio(input.Config.Size), + Resolution: normalizeSeedanceResolution(input.Config.VQuality, input.Config.Model), + Duration: normalizeSeedanceDuration(input.Config.VideoSeconds), } if videoCapabilitySupportsAudio(input) { - body["generate_audio"] = parseBool(input.Config.VideoGenerateAudio, true) + value := parseBool(input.Config.VideoGenerateAudio, true) + body.GenerateAudio = &value } if videoCapabilitySupportsWatermark(input) { - body["watermark"] = parseBool(input.Config.VideoWatermark, false) + value := parseBool(input.Config.VideoWatermark, false) + body.Watermark = &value } if err := postJSON(ctx, input.Config, "/contents/generations/tasks", body, &created); err != nil { return nil, err @@ -2364,64 +2395,74 @@ func newAPIVideoPromptText(input canvasGenerationInput) string { return strings.TrimSpace(input.Prompt) } -func seedanceVideosBody(input canvasGenerationInput) (map[string]interface{}, error) { +func seedanceVideosRequestBody(input canvasGenerationInput) (seedanceVideosRequest, error) { if (len(input.ReferenceVideos) > 0 || len(input.ReferenceAudios) > 0) && len(input.ReferenceImages) == 0 { - return nil, errors.New("Seedance 参考视频或参考音频需要同时连接至少 1 张主参考图") + return seedanceVideosRequest{}, errors.New("Seedance 参考视频或参考音频需要同时连接至少 1 张主参考图") } - body := map[string]interface{}{ - "model": input.Config.Model, - "prompt": seedanceVideosPromptText(input), - "aspect_ratio": normalizeSeedanceVideosRatio(input.Config.Size), - "duration": normalizeSeedanceVideosDuration(input.Config.VideoSeconds), + body := seedanceVideosRequest{ + Model: input.Config.Model, + Prompt: seedanceVideosPromptText(input), + AspectRatio: normalizeSeedanceVideosRatio(input.Config.Size), + Duration: normalizeSeedanceVideosDuration(input.Config.VideoSeconds), } if videoCapabilitySupportsAudio(input) { - body["generate_audio"] = parseBool(input.Config.VideoGenerateAudio, true) + value := parseBool(input.Config.VideoGenerateAudio, true) + body.GenerateAudio = &value } imageURLs := make([]string, 0, len(input.ReferenceImages)) for _, image := range input.ReferenceImages { url, err := openAIImageInputURL(image) if err != nil { - return nil, err + return seedanceVideosRequest{}, err } imageURLs = append(imageURLs, url) } frameImageURLs, err := videoFrameImageURLs(input, imageURLs) if err != nil { - return nil, err + return seedanceVideosRequest{}, err } if len(frameImageURLs) > 0 { - body["image_urls"] = frameImageURLs + body.ImageURLs = frameImageURLs } else if len(imageURLs) > 0 { - body["image_url"] = imageURLs[0] + body.ImageURL = imageURLs[0] if len(imageURLs) > 1 { - body["reference_image_urls"] = imageURLs[1:] + body.ReferenceImageURLs = imageURLs[1:] } } videoURLs := make([]string, 0, len(input.ReferenceVideos)) for _, video := range input.ReferenceVideos { url, err := seedanceVideosMediaURL(video) if err != nil { - return nil, err + return seedanceVideosRequest{}, err } videoURLs = append(videoURLs, url) } if len(videoURLs) > 0 { - body["reference_videos"] = videoURLs + body.ReferenceVideos = videoURLs } audioURLs := make([]string, 0, len(input.ReferenceAudios)) for _, audio := range input.ReferenceAudios { url, err := seedanceVideosMediaURL(audio) if err != nil { - return nil, err + return seedanceVideosRequest{}, err } audioURLs = append(audioURLs, url) } if len(audioURLs) > 0 { - body["reference_audios"] = audioURLs + body.ReferenceAudios = audioURLs } return body, nil } +// 兼容旧的 map 断言调用;实际请求路径使用类型化 Seedance DTO。 +func seedanceVideosBody(input canvasGenerationInput) (map[string]interface{}, error) { + body, err := seedanceVideosRequestBody(input) + if err != nil { + return nil, err + } + return requestAsMap(body) +} + func seedancePromptText(input canvasGenerationInput) string { return strings.TrimSpace(input.Prompt) } @@ -2526,248 +2567,3 @@ func seedanceErrorMessage(state map[string]interface{}) string { } return "" } - -func isPublicMediaURL(value string) bool { - lower := strings.ToLower(value) - return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") -} - -func isSeedanceVideoConfig(config providerConfig) bool { - model := strings.ToLower(config.Model) - return strings.Contains(model, "seedance") || strings.Contains(model, "doubao-seedance") || isArkPlanVideoConfig(config) -} - -func isGrokVideoConfig(config providerConfig) bool { - return strings.Contains(strings.ToLower(strings.TrimSpace(config.Model)), "grok") -} - -func isArkPlanVideoConfig(config providerConfig) bool { - return strings.Contains(strings.ToLower(config.BaseURL), "/api/plan/v3") -} - -func normalizeImageQuality(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "1k": - return "low" - case "2k": - return "medium" - case "4k": - return "high" - default: - return value - } -} - -func normalizePixelSize(value string) string { - value = strings.TrimSpace(value) - if value == "" || value == "auto" { - return "" - } - // 画布按比例保存常用预设;图片接口只接受像素尺寸,必须在请求边界完成转换。 - switch value { - case "1:1": - return "1024x1024" - case "3:2": - return "1536x1024" - case "2:3": - return "1024x1536" - case "4:3": - return "1360x1024" - case "3:4": - return "1024x1360" - case "16:9": - return "1824x1024" - case "9:16": - return "1024x1824" - case "21:9": - return "2352x1008" - } - if strings.Contains(value, "x") { - return value - } - return "" -} - -func normalizeVideoSize(value string) string { - value = strings.TrimSpace(value) - if value == "" || value == "auto" { - return "" - } - if strings.Contains(value, "x") { - return value - } - if value == "9:16" || value == "2:3" || value == "3:4" { - return "720x1280" - } - return "1280x720" -} - -func normalizeVideoResolution(value string) string { - value = strings.TrimSpace(value) - if value == "" || value == "auto" || value == "medium" || value == "high" { - return "720p" - } - if value == "low" { - return "480p" - } - if strings.EqualFold(value, "4k") { - return "2160p" - } - if strings.HasSuffix(value, "p") { - return value - } - return value + "p" -} - -func normalizeXAIVideoDuration(value string) int { - duration, err := strconv.Atoi(strings.TrimSpace(value)) - if err != nil || duration <= 0 { - return 6 - } - return duration -} - -func normalizeXAIVideoResolution(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "480", "480p", "low": - return "480p" - case "1080", "1080p": - return "1080p" - case "2160", "2160p", "4k": - return "2160p" - default: - return "720p" - } -} - -func normalizeXAIVideoAspectRatio(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - allowed := map[string]bool{ - "1:1": true, "16:9": true, "9:16": true, "4:3": true, - "3:4": true, "3:2": true, "2:3": true, - } - if allowed[value] { - return value - } - parts := strings.Split(value, "x") - if len(parts) != 2 { - return "16:9" - } - width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) - height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) - if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { - return "16:9" - } - ratio := float64(width) / float64(height) - candidates := []struct { - name string - ratio float64 - }{ - {name: "1:1", ratio: 1}, - {name: "16:9", ratio: 16.0 / 9}, - {name: "9:16", ratio: 9.0 / 16}, - {name: "4:3", ratio: 4.0 / 3}, - {name: "3:4", ratio: 3.0 / 4}, - {name: "3:2", ratio: 3.0 / 2}, - {name: "2:3", ratio: 2.0 / 3}, - } - bestName := "16:9" - bestDifference := 2.0 - for _, candidate := range candidates { - difference := ratio - candidate.ratio - if difference < 0 { - difference = -difference - } - if difference < bestDifference { - bestName = candidate.name - bestDifference = difference - } - } - return bestName -} - -func normalizeSeedanceDuration(value string) int { - if strings.TrimSpace(value) == "-1" { - return -1 - } - seconds, err := strconv.Atoi(strings.TrimSpace(value)) - if err != nil || seconds <= 0 { - return 5 - } - return seconds -} - -func normalizeSeedanceVideosDuration(value string) int { - return normalizeSeedanceDuration(value) -} - -func normalizeSeedanceRatio(value string) string { - value = strings.TrimSpace(value) - if value == "" || value == "auto" || value == "adaptive" { - return "adaptive" - } - switch value { - case "16:9", "9:16", "1:1", "4:3", "3:4", "21:9": - return value - default: - return "adaptive" - } -} - -func normalizeSeedanceVideosRatio(value string) string { - ratio := normalizeSeedanceRatio(value) - if ratio == "adaptive" { - return "16:9" - } - return ratio -} - -func normalizeSeedanceResolution(value string, model string) string { - resolution := strings.TrimSuffix(strings.TrimSpace(value), "p") - if strings.EqualFold(resolution, "4k") { - resolution = "2160" - } - switch resolution { - case "480", "720", "1080", "2160": - default: - if value == "low" { - resolution = "480" - } else { - resolution = "720" - } - } - if strings.Contains(strings.ToLower(model), "fast") && (resolution == "1080" || resolution == "2160") { - resolution = "720" - } - return resolution + "p" -} - -func parseBool(value string, fallback bool) bool { - switch strings.ToLower(strings.TrimSpace(value)) { - case "true": - return true - case "false": - return false - default: - return fallback - } -} - -func parseFloat(value string, fallback float64) float64 { - number, err := strconv.ParseFloat(strings.TrimSpace(value), 64) - if err != nil || number == 0 { - return fallback - } - return number -} - -func sleepContext(ctx context.Context, duration time.Duration) error { - timer := time.NewTimer(duration) - defer timer.Stop() - select { - case <-ctx.Done(): - return ctx.Err() - case <-timer.C: - return nil - } -} diff --git a/backend/internal/service/provider_request_types.go b/backend/internal/service/provider_request_types.go new file mode 100644 index 000000000..50a955a85 --- /dev/null +++ b/backend/internal/service/provider_request_types.go @@ -0,0 +1,85 @@ +package service + +import "encoding/json" + +func requestAsMap(value interface{}) (map[string]interface{}, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + result := make(map[string]interface{}) + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + return result, nil +} + +type newAPIVideoRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Seconds string `json:"seconds"` + AspectRatio string `json:"aspect_ratio"` + Resolution string `json:"resolution"` + GenerateAudio *bool `json:"generate_audio,omitempty"` + ImageURLs []string `json:"image_urls,omitempty"` + VideoURLs []string `json:"video_urls,omitempty"` + AudioURLs []string `json:"audio_urls,omitempty"` +} + +type seedanceVideosRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + AspectRatio string `json:"aspect_ratio"` + Duration int `json:"duration"` + GenerateAudio *bool `json:"generate_audio,omitempty"` + ImageURL string `json:"image_url,omitempty"` + ReferenceImageURLs []string `json:"reference_image_urls,omitempty"` + ImageURLs []string `json:"image_urls,omitempty"` + ReferenceVideos []string `json:"reference_videos,omitempty"` + ReferenceAudios []string `json:"reference_audios,omitempty"` +} + +type xaiVideoRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Duration int `json:"duration"` + AspectRatio string `json:"aspect_ratio"` + Resolution string `json:"resolution"` + Image *xaiVideoImage `json:"image,omitempty"` +} + +type xaiVideoImage struct { + URL string `json:"url"` +} + +type geminiVeoRequest struct { + Instances []geminiVeoInstance `json:"instances"` + Parameters geminiVeoParameters `json:"parameters"` +} + +type geminiVeoInstance struct { + Prompt string `json:"prompt"` + Image *geminiVeoImage `json:"image,omitempty"` +} + +type geminiVeoImage struct { + BytesBase64Encoded string `json:"bytesBase64Encoded"` + MIMEType string `json:"mimeType"` +} + +type geminiVeoParameters struct { + AspectRatio string `json:"aspectRatio"` + DurationSeconds int `json:"durationSeconds"` + Resolution string `json:"resolution"` + SampleCount int `json:"sampleCount"` +} + +type seedanceAgentPlanRequest struct { + Model string `json:"model"` + Content []map[string]interface{} `json:"content"` + Ratio string `json:"ratio"` + Resolution string `json:"resolution"` + Duration int `json:"duration"` + GenerateAudio *bool `json:"generate_audio,omitempty"` + Watermark *bool `json:"watermark,omitempty"` +} diff --git a/backend/internal/service/provider_video_options.go b/backend/internal/service/provider_video_options.go new file mode 100644 index 000000000..7b5c27588 --- /dev/null +++ b/backend/internal/service/provider_video_options.go @@ -0,0 +1,253 @@ +package service + +import ( + "context" + "strconv" + "strings" + "time" +) + +func isPublicMediaURL(value string) bool { + lower := strings.ToLower(value) + return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") +} + +func isSeedanceVideoConfig(config providerConfig) bool { + model := strings.ToLower(config.Model) + return strings.Contains(model, "seedance") || strings.Contains(model, "doubao-seedance") || isArkPlanVideoConfig(config) +} + +func isGrokVideoConfig(config providerConfig) bool { + return strings.Contains(strings.ToLower(strings.TrimSpace(config.Model)), "grok") +} + +func isArkPlanVideoConfig(config providerConfig) bool { + return strings.Contains(strings.ToLower(config.BaseURL), "/api/plan/v3") +} + +func normalizeImageQuality(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1k": + return "low" + case "2k": + return "medium" + case "4k": + return "high" + default: + return value + } +} + +func normalizePixelSize(value string) string { + value = strings.TrimSpace(value) + if value == "" || value == "auto" { + return "" + } + // 画布按比例保存常用预设;图片接口只接受像素尺寸,必须在请求边界完成转换。 + switch value { + case "1:1": + return "1024x1024" + case "3:2": + return "1536x1024" + case "2:3": + return "1024x1536" + case "4:3": + return "1360x1024" + case "3:4": + return "1024x1360" + case "16:9": + return "1824x1024" + case "9:16": + return "1024x1824" + case "21:9": + return "2352x1008" + } + if strings.Contains(value, "x") { + return value + } + return "" +} + +func normalizeVideoSize(value string) string { + value = strings.TrimSpace(value) + if value == "" || value == "auto" { + return "" + } + if strings.Contains(value, "x") { + return value + } + if value == "9:16" || value == "2:3" || value == "3:4" { + return "720x1280" + } + return "1280x720" +} + +func normalizeVideoResolution(value string) string { + value = strings.TrimSpace(value) + if value == "" || value == "auto" || value == "medium" || value == "high" { + return "720p" + } + if value == "low" { + return "480p" + } + if strings.EqualFold(value, "4k") { + return "2160p" + } + if strings.HasSuffix(value, "p") { + return value + } + return value + "p" +} + +func normalizeXAIVideoDuration(value string) int { + duration, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || duration <= 0 { + return 6 + } + return duration +} + +func normalizeXAIVideoResolution(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "480", "480p", "low": + return "480p" + case "1080", "1080p": + return "1080p" + case "2160", "2160p", "4k": + return "2160p" + default: + return "720p" + } +} + +func normalizeXAIVideoAspectRatio(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + allowed := map[string]bool{ + "1:1": true, "16:9": true, "9:16": true, "4:3": true, + "3:4": true, "3:2": true, "2:3": true, + } + if allowed[value] { + return value + } + parts := strings.Split(value, "x") + if len(parts) != 2 { + return "16:9" + } + width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) + height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return "16:9" + } + ratio := float64(width) / float64(height) + candidates := []struct { + name string + ratio float64 + }{ + {name: "1:1", ratio: 1}, + {name: "16:9", ratio: 16.0 / 9}, + {name: "9:16", ratio: 9.0 / 16}, + {name: "4:3", ratio: 4.0 / 3}, + {name: "3:4", ratio: 3.0 / 4}, + {name: "3:2", ratio: 3.0 / 2}, + {name: "2:3", ratio: 2.0 / 3}, + } + bestName := "16:9" + bestDifference := 2.0 + for _, candidate := range candidates { + difference := ratio - candidate.ratio + if difference < 0 { + difference = -difference + } + if difference < bestDifference { + bestName = candidate.name + bestDifference = difference + } + } + return bestName +} + +func normalizeSeedanceDuration(value string) int { + if strings.TrimSpace(value) == "-1" { + return -1 + } + seconds, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || seconds <= 0 { + return 5 + } + return seconds +} + +func normalizeSeedanceVideosDuration(value string) int { + return normalizeSeedanceDuration(value) +} + +func normalizeSeedanceRatio(value string) string { + value = strings.TrimSpace(value) + if value == "" || value == "auto" || value == "adaptive" { + return "adaptive" + } + switch value { + case "16:9", "9:16", "1:1", "4:3", "3:4", "21:9": + return value + default: + return "adaptive" + } +} + +func normalizeSeedanceVideosRatio(value string) string { + ratio := normalizeSeedanceRatio(value) + if ratio == "adaptive" { + return "16:9" + } + return ratio +} + +func normalizeSeedanceResolution(value string, model string) string { + resolution := strings.TrimSuffix(strings.TrimSpace(value), "p") + if strings.EqualFold(resolution, "4k") { + resolution = "2160" + } + switch resolution { + case "480", "720", "1080", "2160": + default: + if value == "low" { + resolution = "480" + } else { + resolution = "720" + } + } + if strings.Contains(strings.ToLower(model), "fast") && (resolution == "1080" || resolution == "2160") { + resolution = "720" + } + return resolution + "p" +} + +func parseBool(value string, fallback bool) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "true": + return true + case "false": + return false + default: + return fallback + } +} + +func parseFloat(value string, fallback float64) float64 { + number, err := strconv.ParseFloat(strings.TrimSpace(value), 64) + if err != nil || number == 0 { + return fallback + } + return number +} + +func sleepContext(ctx context.Context, duration time.Duration) error { + timer := time.NewTimer(duration) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 46775e709..0c89b64c6 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -164,20 +164,20 @@ type agentStoryboardPlan struct { } type agentStoryboardShot struct { - Title string `json:"title"` - Description string `json:"description"` - Duration int `json:"durationSeconds"` - Dialogue string `json:"dialogue"` - ShotSize string `json:"shotSize"` - Emotion string `json:"emotion"` - Lighting string `json:"lightingAndAtmosphere"` - AudioEffects string `json:"audioEffects"` - VisualPrompt string `json:"visualPrompt"` - VideoPrompt string `json:"videoPrompt"` - Camera string `json:"camera"` - Motion string `json:"motion"` - TimeBeats string `json:"timeBeats"` - Negative string `json:"negativePrompt"` + Title string `json:"title"` + Description string `json:"description"` + Duration int `json:"durationSeconds"` + Dialogue string `json:"dialogue"` + ShotSize string `json:"shotSize"` + Emotion string `json:"emotion"` + Lighting string `json:"lightingAndAtmosphere"` + AudioEffects string `json:"audioEffects"` + VisualPrompt string `json:"visualPrompt"` + VideoPrompt string `json:"videoPrompt"` + Camera string `json:"camera"` + Motion string `json:"motion"` + TimeBeats string `json:"timeBeats"` + Negative string `json:"negativePrompt"` AssetTags []string `json:"assetTags"` CharacterIDs []string `json:"characterIds"` Intent string `json:"narrativeIntent"` @@ -196,6 +196,7 @@ func New(repo *repository.Repository, dataDir string) *Service { func (s *Service) StartWorker() { s.startTextReplayCleanup() s.startProviderCancellationReconciliation() + s.startBillingReviewAudit() go func() { slots := make(chan struct{}, maxChannelConcurrencyLimit) dispatch := func() { @@ -1763,10 +1764,10 @@ func shotDescription(shot agentStoryboardShot) string { func storyboardImagePromptValues(projectStyle string, styleGuide string, shot agentStoryboardShot) map[string]string { negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、风格突变和塑料材质") return map[string]string{ - "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), - "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 360), + "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), + "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 360), "表演起始状态": compactPromptText(shot.Performance, 180), - "负面要求": compactPromptText(negative, 140), + "负面要求": compactPromptText(negative, 140), } } @@ -1787,15 +1788,15 @@ func storyboardVideoPromptValues(projectStyle string, styleGuide string, shot ag timeBeats := defaultString(strings.TrimSpace(shot.TimeBeats), fmt.Sprintf("0-%d秒:%s", shot.Duration, strings.TrimSpace(shot.Description))) negative := defaultString(strings.TrimSpace(shot.Negative), "禁止换脸、服装变化、手部畸形、乱码、闪烁、风格突变和动作僵硬") values := map[string]string{ - "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), - "镜头意图": compactPromptText(shot.Intent+";观众视点:"+shot.ViewerPOV+";情绪:"+shot.Emotion, 150), - "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 280), + "项目视觉": storyboardProjectVisualSummary(projectStyle, styleGuide), + "镜头意图": compactPromptText(shot.Intent+";观众视点:"+shot.ViewerPOV+";情绪:"+shot.Emotion, 150), + "首帧构图": compactPromptText(shot.VisualPrompt+";光影:"+shot.Lighting, 280), "表演与调度": compactPromptText(shot.Performance, 180), - "摄影机": compactPromptText(strings.TrimSpace(shot.ShotSize)+";"+camera+";主运镜:"+motion, 220), - "时间节拍": compactPromptText(timeBeats, 240), + "摄影机": compactPromptText(strings.TrimSpace(shot.ShotSize)+";"+camera+";主运镜:"+motion, 220), + "时间节拍": compactPromptText(timeBeats, 240), "运动与结尾": compactPromptText(shot.VideoPrompt+";连续性结尾:"+shot.ContinuityOut, 240), - "声音": compactPromptText(strings.TrimSpace(shot.Dialogue)+";音效:"+strings.TrimSpace(shot.AudioEffects), 160), - "负面要求": compactPromptText(negative, 160), + "声音": compactPromptText(strings.TrimSpace(shot.Dialogue)+";音效:"+strings.TrimSpace(shot.AudioEffects), 160), + "负面要求": compactPromptText(negative, 160), } if len(shot.MustHave) > 0 { priority := "必须完成:" + strings.Join(shot.MustHave, ";") @@ -1945,65 +1946,6 @@ func (s *Service) markSessionFailed(task model.Task, message string) error { } return s.repo.Create(&model.Message{ID: newID(), UserID: task.UserID, SessionID: task.SessionID, Role: "assistant", Content: defaultString(message, "会话任务失败。")}) } - -func buildAgentResult(task model.Task) (map[string]any, []map[string]any) { - title := strings.TrimSpace(task.Prompt) - if len([]rune(title)) > 28 { - title = string([]rune(title)[:28]) + "..." - } - result := map[string]any{ - "taskId": task.ID, - "operation": task.Operation, - "provider": defaultString(task.Provider, "internal-agent"), - "model": defaultString(task.Model, "workflow-router"), - "plan": []map[string]any{ - {"kind": "script", "title": "创意脚本", "content": task.Prompt}, - {"kind": "scene", "title": "主场景", "content": "根据用户输入拆解为可生成的视频场景。"}, - {"kind": "shot", "title": "镜头 1", "content": "建立画面、主体、风格和运镜。"}, - {"kind": "final", "title": "成片", "content": "等待视频生成 Provider 回填成片结果。"}, - }, - } - ops := []map[string]any{ - nodeOp("script-"+task.ID, "text", "剧本 · "+title, 0, 0, "script", task.Prompt), - nodeOp("scene-"+task.ID, "text", "场景 · 主场景", 380, 0, "scene", "主场景设定、角色关系、视觉风格。"), - nodeOpWithMetadata("shot-"+task.ID, "video", "分镜 · 镜头 1", 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": "text_to_video"}), - nodeOp("final-"+task.ID, "video", "成片 · 待生成", 1140, 0, "final", ""), - connectOp("script-"+task.ID, "scene-"+task.ID), - connectOp("scene-"+task.ID, "shot-"+task.ID), - connectOp("shot-"+task.ID, "final-"+task.ID), - } - return result, ops -} - -func buildVideoWorkflowResult(task model.Task) (map[string]any, []map[string]any) { - title := strings.TrimSpace(task.Prompt) - if len([]rune(title)) > 28 { - title = string([]rune(title)[:28]) + "..." - } - operation := defaultString(task.Operation, strings.TrimPrefix(task.Type, "video_")) - result := map[string]any{ - "taskId": task.ID, - "operation": operation, - "provider": defaultString(task.Provider, "internal-agent"), - "model": defaultString(task.Model, "workflow-router"), - "plan": []map[string]any{ - {"kind": "reference_set", "title": "参考素材组", "content": "收集原视频、参考图、参考音频和版本样片。"}, - {"kind": "shot", "title": "编辑镜头", "content": task.Prompt}, - {"kind": "final", "title": "结果版本", "content": "等待 provider 生成或人工确认后回填版本结果。"}, - }, - } - ops := []map[string]any{ - nodeOp("video-brief-"+task.ID, "text", "编辑需求 · "+title, 0, 0, "script", task.Prompt), - nodeOpWithMetadata("video-ref-"+task.ID, "text", "参考素材组", 380, 0, map[string]any{"workflowKind": "reference_set", "status": "idle", "content": "原片、参考图、参考音频、风格板或历史版本。", "videoEditOperation": operation}), - nodeOpWithMetadata("video-shot-"+task.ID, "video", "视频任务 · "+operation, 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": operation}), - nodeOpWithMetadata("video-result-"+task.ID, "video", "结果版本 · 待回填", 1140, 0, map[string]any{"workflowKind": "final", "status": "idle", "videoEditOperation": operation, "versionLabel": "v1"}), - connectOp("video-brief-"+task.ID, "video-ref-"+task.ID), - connectOp("video-ref-"+task.ID, "video-shot-"+task.ID), - connectOp("video-shot-"+task.ID, "video-result-"+task.ID), - } - return result, ops -} - func nodeOp(id string, nodeType string, title string, x int, y int, workflowKind string, content string) map[string]any { return nodeOpWithMetadata(id, nodeType, title, x, y, map[string]any{"content": content, "workflowKind": workflowKind, "status": "idle"}) } diff --git a/backend/internal/service/service_workflow_results.go b/backend/internal/service/service_workflow_results.go new file mode 100644 index 000000000..fb9f4c9ba --- /dev/null +++ b/backend/internal/service/service_workflow_results.go @@ -0,0 +1,65 @@ +package service + +import ( + "strings" + + "infinite-canvas/backend/internal/model" +) + +func buildAgentResult(task model.Task) (map[string]any, []map[string]any) { + title := strings.TrimSpace(task.Prompt) + if len([]rune(title)) > 28 { + title = string([]rune(title)[:28]) + "..." + } + result := map[string]any{ + "taskId": task.ID, + "operation": task.Operation, + "provider": defaultString(task.Provider, "internal-agent"), + "model": defaultString(task.Model, "workflow-router"), + "plan": []map[string]any{ + {"kind": "script", "title": "创意脚本", "content": task.Prompt}, + {"kind": "scene", "title": "主场景", "content": "根据用户输入拆解为可生成的视频场景。"}, + {"kind": "shot", "title": "镜头 1", "content": "建立画面、主体、风格和运镜。"}, + {"kind": "final", "title": "成片", "content": "等待视频生成 Provider 回填成片结果。"}, + }, + } + ops := []map[string]any{ + nodeOp("script-"+task.ID, "text", "剧本 · "+title, 0, 0, "script", task.Prompt), + nodeOp("scene-"+task.ID, "text", "场景 · 主场景", 380, 0, "scene", "主场景设定、角色关系、视觉风格。"), + nodeOpWithMetadata("shot-"+task.ID, "video", "分镜 · 镜头 1", 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": "text_to_video"}), + nodeOp("final-"+task.ID, "video", "成片 · 待生成", 1140, 0, "final", ""), + connectOp("script-"+task.ID, "scene-"+task.ID), + connectOp("scene-"+task.ID, "shot-"+task.ID), + connectOp("shot-"+task.ID, "final-"+task.ID), + } + return result, ops +} + +func buildVideoWorkflowResult(task model.Task) (map[string]any, []map[string]any) { + title := strings.TrimSpace(task.Prompt) + if len([]rune(title)) > 28 { + title = string([]rune(title)[:28]) + "..." + } + operation := defaultString(task.Operation, strings.TrimPrefix(task.Type, "video_")) + result := map[string]any{ + "taskId": task.ID, + "operation": operation, + "provider": defaultString(task.Provider, "internal-agent"), + "model": defaultString(task.Model, "workflow-router"), + "plan": []map[string]any{ + {"kind": "reference_set", "title": "参考素材组", "content": "收集原视频、参考图、参考音频和版本样片。"}, + {"kind": "shot", "title": "编辑镜头", "content": task.Prompt}, + {"kind": "final", "title": "结果版本", "content": "等待 provider 生成或人工确认后回填版本结果。"}, + }, + } + ops := []map[string]any{ + nodeOp("video-brief-"+task.ID, "text", "编辑需求 · "+title, 0, 0, "script", task.Prompt), + nodeOpWithMetadata("video-ref-"+task.ID, "text", "参考素材组", 380, 0, map[string]any{"workflowKind": "reference_set", "status": "idle", "content": "原片、参考图、参考音频、风格板或历史版本。", "videoEditOperation": operation}), + nodeOpWithMetadata("video-shot-"+task.ID, "video", "视频任务 · "+operation, 760, 0, map[string]any{"workflowKind": "shot", "status": "idle", "generationMode": "video", "prompt": task.Prompt, "composerContent": task.Prompt, "videoEditOperation": operation}), + nodeOpWithMetadata("video-result-"+task.ID, "video", "结果版本 · 待回填", 1140, 0, map[string]any{"workflowKind": "final", "status": "idle", "videoEditOperation": operation, "versionLabel": "v1"}), + connectOp("video-brief-"+task.ID, "video-ref-"+task.ID), + connectOp("video-ref-"+task.ID, "video-shot-"+task.ID), + connectOp("video-shot-"+task.ID, "video-result-"+task.ID), + } + return result, ops +} diff --git a/web/package.json b/web/package.json index a2c2664d8..cd9dc5834 100644 --- a/web/package.json +++ b/web/package.json @@ -6,6 +6,8 @@ "scripts": { "dev": "vite --host 0.0.0.0 --port 3000", "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", + "test": "bun test test/agent-prompt-cache.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-generation-layout.test.ts test/canvas-leafer-viewport.test.ts test/client-id.test.ts", "start": "vite preview --host 0.0.0.0 --port 3000", "format": "prettier --write .", "format:check": "prettier --check ." diff --git a/web/src/components/audio-settings-panel.tsx b/web/src/components/audio-settings-panel.tsx index d88e62626..22ee09864 100644 --- a/web/src/components/audio-settings-panel.tsx +++ b/web/src/components/audio-settings-panel.tsx @@ -17,7 +17,7 @@ type AudioSettingsPanelProps = { className?: string; }; -export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[320px] space-y-4 rounded-2xl px-1 py-0.5" }: AudioSettingsPanelProps) { +export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[var(--panel-width-compact)] space-y-4 rounded-2xl px-1 py-0.5" }: AudioSettingsPanelProps) { const voice = normalizeAudioVoiceValue(config.audioVoice); const format = normalizeAudioFormatValue(config.audioFormat); const speed = normalizeAudioSpeedValue(config.audioSpeed); @@ -82,7 +82,13 @@ export function AudioSettingsPanel({ config, onConfigChange, theme, showTitle = function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) { return ( - ); diff --git a/web/src/components/canvas/canvas-emotion-workspace.tsx b/web/src/components/canvas/canvas-emotion-workspace.tsx index 304d68d14..f04dbddbc 100644 --- a/web/src/components/canvas/canvas-emotion-workspace.tsx +++ b/web/src/components/canvas/canvas-emotion-workspace.tsx @@ -3,21 +3,11 @@ import { useEffect, useLayoutEffect, useRef, useState, type PointerEvent as Reac import { createPortal } from "react-dom"; import { LoaderCircle, ScanFace, SquareDashedMousePointer, X } from "lucide-react"; -import { - CanvasNodeEmotionPanel, - type CanvasEmotionCharacter, - type CanvasImageEmotionPayload, -} from "@/components/canvas/canvas-node-emotion-panel"; +import { CanvasNodeEmotionPanel, type CanvasEmotionCharacter, type CanvasImageEmotionPayload } from "@/components/canvas/canvas-node-emotion-panel"; import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { canvasThemes } from "@/lib/canvas-theme"; -import { - buildEmotionImageArtifacts, - buildEmotionPrompt, - neutralEmotionPreset, - type CanvasEmotionPreset, - type CanvasFaceBox, -} from "@/lib/canvas/canvas-emotion"; +import { buildEmotionImageArtifacts, buildEmotionPrompt, neutralEmotionPreset, type CanvasEmotionPreset, type CanvasFaceBox } from "@/lib/canvas/canvas-emotion"; import { detectCanvasFaces } from "@/lib/canvas/canvas-face-detection"; import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport"; import { useThemeStore } from "@/stores/use-theme-store"; @@ -141,16 +131,7 @@ export function CanvasEmotionWorkspace({ node, viewport, containerRef, onClose, }} onFaceSelect={selectFace} /> - + {activeCharacter && (status === "editing" || status === "generating") ? ( @@ -163,7 +144,10 @@ export function CanvasEmotionWorkspace({ node, viewport, containerRef, onClose, preset={preset} generating={status === "generating"} error={error} - onSelectCharacter={(id) => { setActiveCharacterId(id); setError(""); }} + onSelectCharacter={(id) => { + setActiveCharacterId(id); + setError(""); + }} onManualSelect={beginManualSelection} onPresetChange={setPreset} onClose={onClose} @@ -177,7 +161,21 @@ export function CanvasEmotionWorkspace({ node, viewport, containerRef, onClose, ); } -function FaceSelectionOverlay({ node, viewport, containerRef, imageWidth, imageHeight, faces, characters, activeCharacterId, status, manualDraft, onManualDraftChange, onManualComplete, onFaceSelect }: { +function FaceSelectionOverlay({ + node, + viewport, + containerRef, + imageWidth, + imageHeight, + faces, + characters, + activeCharacterId, + status, + manualDraft, + onManualDraftChange, + onManualComplete, + onFaceSelect, +}: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject; @@ -209,39 +207,71 @@ function FaceSelectionOverlay({ node, viewport, containerRef, imageWidth, imageH data-canvas-no-zoom className={`absolute z-[var(--z-modal)] overflow-hidden rounded-[var(--r-2xl)] ${status === "manual" ? "cursor-crosshair touch-none" : "pointer-events-none"}`} style={{ left: 0, top: 0, width: node.width * viewport.k, height: node.height * viewport.k }} - onPointerDown={status === "manual" ? (event) => { - event.preventDefault(); - event.stopPropagation(); - event.currentTarget.setPointerCapture(event.pointerId); - const start = pointerPosition(event); - dragStartRef.current = start; - onManualDraftChange({ id: `manual-${Date.now()}`, x: start.x, y: start.y, width: 0, height: 0, source: "manual" }); - } : undefined} - onPointerMove={status === "manual" ? (event) => { - const start = dragStartRef.current; - if (!start) return; - const current = pointerPosition(event); - onManualDraftChange({ id: manualDraft?.id || `manual-${Date.now()}`, x: Math.min(start.x, current.x), y: Math.min(start.y, current.y), width: Math.abs(current.x - start.x), height: Math.abs(current.y - start.y), source: "manual" }); - } : undefined} - onPointerUp={status === "manual" ? (event) => { - const start = dragStartRef.current; - dragStartRef.current = null; - if (!start) return; - const current = pointerPosition(event); - const box = { id: manualDraft?.id || `manual-${Date.now()}`, x: Math.min(start.x, current.x), y: Math.min(start.y, current.y), width: Math.abs(current.x - start.x), height: Math.abs(current.y - start.y), source: "manual" as const }; - if (box.width >= Math.max(18, imageWidth * 0.025) && box.height >= Math.max(18, imageHeight * 0.025)) onManualComplete(box); - else onManualDraftChange(null); - } : undefined} - onPointerCancel={status === "manual" ? () => { - dragStartRef.current = null; - onManualDraftChange(null); - } : undefined} + onPointerDown={ + status === "manual" + ? (event) => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + const start = pointerPosition(event); + dragStartRef.current = start; + onManualDraftChange({ id: `manual-${Date.now()}`, x: start.x, y: start.y, width: 0, height: 0, source: "manual" }); + } + : undefined + } + onPointerMove={ + status === "manual" + ? (event) => { + const start = dragStartRef.current; + if (!start) return; + const current = pointerPosition(event); + onManualDraftChange({ + id: manualDraft?.id || `manual-${Date.now()}`, + x: Math.min(start.x, current.x), + y: Math.min(start.y, current.y), + width: Math.abs(current.x - start.x), + height: Math.abs(current.y - start.y), + source: "manual", + }); + } + : undefined + } + onPointerUp={ + status === "manual" + ? (event) => { + const start = dragStartRef.current; + dragStartRef.current = null; + if (!start) return; + const current = pointerPosition(event); + const box = { + id: manualDraft?.id || `manual-${Date.now()}`, + x: Math.min(start.x, current.x), + y: Math.min(start.y, current.y), + width: Math.abs(current.x - start.x), + height: Math.abs(current.y - start.y), + source: "manual" as const, + }; + if (box.width >= Math.max(18, imageWidth * 0.025) && box.height >= Math.max(18, imageHeight * 0.025)) onManualComplete(box); + else onManualDraftChange(null); + } + : undefined + } + onPointerCancel={ + status === "manual" + ? () => { + dragStartRef.current = null; + onManualDraftChange(null); + } + : undefined + } > - {faces.map((face) => )} + {faces.map((face) => ( + + ))} {manualDraft ? : null} @@ -256,23 +286,69 @@ function FaceSelectionOverlay({ node, viewport, containerRef, imageWidth, imageH type="button" aria-label={selected ? `选择${selected.name}` : "选择此人脸"} className={`absolute rounded-[var(--r-md)] border-2 ${interactive ? "pointer-events-auto" : "pointer-events-none"}`} - style={{ left: `${(face.x / imageWidth) * 100}%`, top: `${(face.y / imageHeight) * 100}%`, width: `${(face.width / imageWidth) * 100}%`, height: `${(face.height / imageHeight) * 100}%`, borderColor: active ? theme.accent.primary : "rgba(255,255,255,.94)", boxShadow: active ? `0 0 0 3px ${theme.accent.primarySoft}, 0 8px 24px rgba(0,0,0,.24)` : "0 8px 20px rgba(0,0,0,.18)" }} + style={{ + left: `${(face.x / imageWidth) * 100}%`, + top: `${(face.y / imageHeight) * 100}%`, + width: `${(face.width / imageWidth) * 100}%`, + height: `${(face.height / imageHeight) * 100}%`, + borderColor: active ? theme.accent.primary : "rgba(255,255,255,.94)", + boxShadow: active ? `0 0 0 3px ${theme.accent.primarySoft}, 0 8px 24px rgba(0,0,0,.24)` : "0 8px 20px rgba(0,0,0,.18)", + }} whileHover={{ scale: 1.025 }} whileTap={{ scale: 0.985 }} transition={aceternityMotion.spring.dock} onPointerDown={(event) => event.stopPropagation()} - onClick={(event) => { event.stopPropagation(); onFaceSelect(face); }} + onClick={(event) => { + event.stopPropagation(); + onFaceSelect(face); + }} > - {selected ? {selected.name} : null} + {selected ? ( + + {selected.name} + + ) : null} ); })} - {manualDraft ?
: null} + {manualDraft ? ( +
+ ) : null}
); } -function SelectionToolbar({ node, viewport, containerRef, status, faceCount, error, onManualSelect, onClose }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject; status: WorkspaceStatus; faceCount: number; error: string; onManualSelect: () => void; onClose: () => void }) { +function SelectionToolbar({ + node, + viewport, + containerRef, + status, + faceCount, + error, + onManualSelect, + onClose, +}: { + node: CanvasNodeData; + viewport: ViewportTransform; + containerRef: RefObject; + status: WorkspaceStatus; + faceCount: number; + error: string; + onManualSelect: () => void; + onClose: () => void; +}) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const reducedMotion = useReducedMotion(); const toolbarRef = useRef(null); @@ -287,16 +363,29 @@ function SelectionToolbar({ node, viewport, containerRef, status, faceCount, err initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: -5, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.panel} - className="aceternity-floating-panel absolute z-[var(--z-modal-overlay)] flex h-12 w-[420px] max-w-[calc(100%_-_24px)] items-center rounded-[15px] border px-2 backdrop-blur-2xl" + className="aceternity-floating-panel absolute z-[var(--z-modal-overlay)] flex h-12 w-[420px] max-w-[calc(100%_-_24px)] items-center rounded-[var(--dock-radius-tight)] border px-2 backdrop-blur-2xl" style={{ left: 12, top: 76, background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 22px 60px ${theme.spatial.shadow}` }} onPointerDown={(event) => event.stopPropagation()} >
- + - {status === "detecting" ? : } + + {status === "detecting" ? : } + {label} - {status !== "detecting" ? : null} + {status !== "detecting" ? ( + + ) : null}
); @@ -305,10 +394,20 @@ function SelectionToolbar({ node, viewport, containerRef, status, faceCount, err function EmotionPanelOverlay({ node, viewport, containerRef, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject; children: ReactNode }) { const panelRef = useRef(null); useScreenAnchor(panelRef, node, viewport, containerRef, (next, container) => panelScreenRect(node, next, container, panelRef.current)); - return
event.stopPropagation()}>{children}
; + return ( +
event.stopPropagation()}> + {children} +
+ ); } -function useScreenAnchor(ref: RefObject, node: CanvasNodeData, viewport: ViewportTransform, containerRef: RefObject, resolve: (viewport: ViewportTransform, container: HTMLDivElement) => { left: number; top: number; width?: number; height?: number }) { +function useScreenAnchor( + ref: RefObject, + node: CanvasNodeData, + viewport: ViewportTransform, + containerRef: RefObject, + resolve: (viewport: ViewportTransform, container: HTMLDivElement) => { left: number; top: number; width?: number; height?: number }, +) { const resolveRef = useRef(resolve); resolveRef.current = resolve; useLayoutEffect(() => { @@ -327,7 +426,10 @@ function useScreenAnchor(ref: RefObject, node: CanvasNodeDat observer.observe(container); observer.observe(element); const unsubscribe = subscribeCanvasViewportPreview(container, update); - return () => { observer.disconnect(); unsubscribe(); }; + return () => { + observer.disconnect(); + unsubscribe(); + }; }, [containerRef, node.height, node.id, node.metadata?.freeResize, node.position.x, node.position.y, node.width, ref, viewport]); } @@ -351,9 +453,7 @@ function toolbarScreenRect(node: CanvasNodeData, viewport: ViewportTransform, co const viewportTop = container.scrollTop; const left = clamp(nodeRect.left + nodeRect.width / 2 - width / 2, viewportLeft + 12, Math.max(viewportLeft + 12, viewportLeft + container.clientWidth - width - 12)); const above = nodeRect.top - height - 10; - const top = above >= viewportTop + 72 - ? above - : clamp(nodeRect.top + nodeRect.height + 10, viewportTop + 72, Math.max(viewportTop + 72, viewportTop + container.clientHeight - height - 12)); + const top = above >= viewportTop + 72 ? above : clamp(nodeRect.top + nodeRect.height + 10, viewportTop + 72, Math.max(viewportTop + 72, viewportTop + container.clientHeight - height - 12)); return { left, top }; } diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index 5cf98d243..0b03f78fa 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -55,21 +55,21 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴) const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); const priceChannel = resolveModelChannel(config, config.model); - const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? generationCount : 1, seconds: mode === "video" ? config.videoSeconds : 1 }); + const credits = requestCreditCost({ + channelMode: priceChannel.scope === "system" ? "remote" : "local", + modelCosts: priceChannel.modelCosts, + model: modelOptionName(config.model), + count: mode === "image" ? generationCount : 1, + seconds: mode === "video" ? config.videoSeconds : 1, + }); const activeReferenceCount = mentionReferences.filter((item) => item.active && item.kind !== "skill").length; - const videoFrameOptions = mentionReferences - .filter((item) => item.active && item.kind === "image") - .map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl })); + const videoFrameOptions = mentionReferences.filter((item) => item.active && item.kind === "image").map((item) => ({ nodeId: item.nodeId, label: item.label, title: item.title, previewUrl: item.previewUrl })); const darkSurface = themeName === "dark"; const monochromeAccent = theme.node.activeStroke; const shellBorder = darkSurface ? "rgba(255,255,255,.08)" : "rgba(15,23,42,.08)"; const insetBorder = darkSurface ? "rgba(255,255,255,.06)" : "rgba(15,23,42,.07)"; - const shellSurface = darkSurface - ? theme.canvas.background - : "rgba(250,251,252,.97)"; - const composerSurface = darkSurface - ? theme.canvas.background - : "rgba(15,23,42,.025)"; + const shellSurface = darkSurface ? theme.canvas.background : "rgba(250,251,252,.97)"; + const composerSurface = darkSurface ? theme.canvas.background : "rgba(15,23,42,.025)"; const controlSurface = darkSurface ? theme.canvas.background : "rgba(15,23,42,.045)"; const controlsSurface = darkSurface ? controlSurface : "transparent"; const shellShadow = darkSurface ? `0 22px 60px ${theme.spatial.shadow}, 0 2px 8px rgba(0,0,0,.22)` : "0 12px 32px rgba(15,23,42,.10), 0 1px 2px rgba(15,23,42,.06)"; @@ -82,7 +82,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi const canExpandPrompt = mode === "image" || mode === "video"; const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture); const updatePromptContentHeight = useCallback((height: number) => { - setPromptContentHeight((current) => Math.abs(current - height) < 1 ? current : height); + setPromptContentHeight((current) => (Math.abs(current - height) < 1 ? current : height)); }, []); useEffect(() => { @@ -141,11 +141,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi const renderComposerHeader = (expanded: boolean) => (
{isPortraitTexture ? ( - onConfigChange(node.id, { portraitTexture })} - /> + onConfigChange(node.id, { portraitTexture })} /> ) : (
@@ -154,16 +150,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi {modeDisplayName(mode)}创作
)} - {!simpleMode ? ( - - ) : null} + {!simpleMode ? : null}
{activeReferenceCount ? } label={`已连接 ${activeReferenceCount} 个`} /> : null} {!expanded && canExpandPrompt ? ( @@ -183,59 +170,86 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
); - const renderComposerControls = (expanded: boolean) => simpleMode ? ( -
- - {activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"} - - -
- ) : ( -
-
- onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => navigateToSettings({ continueCreation: true })} showSelectedPrice={false} /> -
-
- {mode === "image" ? ( - onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} - onMissingConfig={() => navigateToSettings({ continueCreation: true })} - onOpenChange={expanded ? undefined : onImageSettingsOpenChange} - /> - ) : mode === "video" ? ( - onConfigChange(node.id, videoConfigPatch(key, value))} /> - ) : mode === "audio" ? ( - onConfigChange(node.id, audioConfigPatch(key, value))} /> - ) : null} - {creditsEnabled ? : null} + const renderComposerControls = (expanded: boolean) => + simpleMode ? ( +
+ + {activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"} +
-
- ); + ) : ( +
+
+ onConfigChange(node.id, { model })} + capability={mode} + onMissingConfig={() => navigateToSettings({ continueCreation: true })} + showSelectedPrice={false} + /> +
+
+ {mode === "image" ? ( + onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} + onMissingConfig={() => navigateToSettings({ continueCreation: true })} + onOpenChange={expanded ? undefined : onImageSettingsOpenChange} + /> + ) : mode === "video" ? ( + onConfigChange(node.id, videoConfigPatch(key, value))} + /> + ) : mode === "audio" ? ( + onConfigChange(node.id, audioConfigPatch(key, value))} + /> + ) : null} + {creditsEnabled ? : null} + +
+
+ ); return (
{ setExpandedPresetOpen(false); setExpandedPromptOpen(false); }} - styles={{ container: { display: "flex", height: "min(440px, calc(100vh - 40px))", flexDirection: "column", borderRadius: 12, border: `1px solid ${shellBorder}`, padding: 0, overflow: "hidden", background: shellSurface, boxShadow: modalShadow }, body: { minHeight: 0, flex: 1, padding: 0 } }} + onCancel={() => { + setExpandedPresetOpen(false); + setExpandedPromptOpen(false); + }} + styles={{ + container: { display: "flex", height: "min(440px, calc(100vh - 40px))", flexDirection: "column", borderRadius: 12, border: `1px solid ${shellBorder}`, padding: 0, overflow: "hidden", background: shellSurface, boxShadow: modalShadow }, + body: { minHeight: 0, flex: 1, padding: 0 }, + }} >
{renderComposerHeader(true)}
-
+
+ {icon} {label} @@ -376,7 +390,9 @@ function ConnectedReferenceShelf({ references, theme, onInsert }: { references: {index + 1} - + + + ))}
@@ -399,7 +415,12 @@ function ReferenceThumbnail({ reference }: { reference: CanvasResourceReference function GenerationCostBadge({ credits, theme }: { credits: number | null; theme: CanvasTheme }) { if (credits === null) return null; return ( - + {credits.toLocaleString()} diff --git a/web/src/components/canvas/canvas-preset-picker.tsx b/web/src/components/canvas/canvas-preset-picker.tsx index a6d19d54e..f1878e0d9 100644 --- a/web/src/components/canvas/canvas-preset-picker.tsx +++ b/web/src/components/canvas/canvas-preset-picker.tsx @@ -67,7 +67,23 @@ const BUILTIN_PRESETS: CanvasPromptPreset[] = [ }, ]; -export function CanvasPresetPicker({ mode, skillReferences = [], open, onOpenChange, onSelect, compact = false, dense = false }: { mode: CanvasGenerationMode; skillReferences?: CanvasResourceReference[]; open?: boolean; onOpenChange?: (open: boolean) => void; onSelect: (preset: CanvasPromptPreset) => void; compact?: boolean; dense?: boolean }) { +export function CanvasPresetPicker({ + mode, + skillReferences = [], + open, + onOpenChange, + onSelect, + compact = false, + dense = false, +}: { + mode: CanvasGenerationMode; + skillReferences?: CanvasResourceReference[]; + open?: boolean; + onOpenChange?: (open: boolean) => void; + onSelect: (preset: CanvasPromptPreset) => void; + compact?: boolean; + dense?: boolean; +}) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [internalOpen, setInternalOpen] = useState(false); const [query, setQuery] = useState(""); @@ -80,43 +96,89 @@ export function CanvasPresetPicker({ mode, skillReferences = [], open, onOpenCha const presets = useMemo(() => { const skills = skillReferences.flatMap((reference): CanvasPromptPreset[] => { if (!reference.skill) return []; - return [{ id: `skill:${reference.skill.skill_id}`, name: reference.skill.skill_name, description: reference.skill.description || reference.skill.instruction || "已加入技能", prompt: `@${reference.skill.skill_name} `, modes: ["text", "image", "video", "audio"], source: "skill" }]; + return [ + { + id: `skill:${reference.skill.skill_id}`, + name: reference.skill.skill_name, + description: reference.skill.description || reference.skill.instruction || "已加入技能", + prompt: `@${reference.skill.skill_name} `, + modes: ["text", "image", "video", "audio"], + source: "skill", + }, + ]; }); const normalized = query.trim().toLowerCase(); return [...BUILTIN_PRESETS.filter((preset) => preset.modes.includes(mode)), ...skills].filter((preset) => !normalized || `${preset.name} ${preset.description}`.toLowerCase().includes(normalized)); }, [mode, query, skillReferences]); const content = ( -
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> - } placeholder="搜索预设或已加入技能" value={query} onChange={(event) => setQuery(event.target.value)} /> +
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> + } + placeholder="搜索预设或已加入技能" + value={query} + onChange={(event) => setQuery(event.target.value)} + />
- {presets.length ? presets.map((preset) => ( - - )) :
没有匹配的预设
} + + + {preset.name} + + {preset.source === "skill" ? "技能" : "预设"} + + + + {preset.description} + + + + )) + ) : ( +
+ 没有匹配的预设 +
+ )}
); return ( - - diff --git a/web/src/components/canvas/canvas-project-sidebar.tsx b/web/src/components/canvas/canvas-project-sidebar.tsx index 2cdfb351f..0d17a1ed7 100644 --- a/web/src/components/canvas/canvas-project-sidebar.tsx +++ b/web/src/components/canvas/canvas-project-sidebar.tsx @@ -1,23 +1,7 @@ import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { - ArrowUpRight, - BookOpenText, - ChevronLeft, - ChevronRight, - Crosshair, - FolderKanban, - GripVertical, - Images, - LocateFixed, - LoaderCircle, - Palette, - Plus, - Search, - Settings2, - X, -} from "lucide-react"; +import { ArrowUpRight, BookOpenText, ChevronLeft, ChevronRight, Crosshair, FolderKanban, GripVertical, Images, LocateFixed, LoaderCircle, Palette, Plus, Search, Settings2, X } from "lucide-react"; import { Link } from "react-router"; import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal"; @@ -119,8 +103,12 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate if (collapsed) { return ( ); } @@ -128,29 +116,77 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate return (
- +
- {selectedUnit ? unit.id === selectedUnit.id) + 1} loading={selectedUnitQuery.isLoading} loadError={selectedUnitQuery.isError} adding={addingId === selectedUnit.id} onAdd={() => insertChapter(selectedUnit, selectedUnitQuery.data?.unit.sourceText)} onClose={() => setSelectedId("")} /> : null} + {selectedUnit ? ( + unit.id === selectedUnit.id) + 1} + loading={selectedUnitQuery.isLoading} + loadError={selectedUnitQuery.isError} + adding={addingId === selectedUnit.id} + onAdd={() => insertChapter(selectedUnit, selectedUnitQuery.data?.unit.sourceText)} + onClose={() => setSelectedId("")} + /> + ) : null} ); } -function ChapterPreview({ projectId, unit, chapterNumber, loading, loadError, adding, onAdd, onClose }: { projectId: string; unit: ProjectUnit; chapterNumber: number; loading: boolean; loadError: boolean; adding: boolean; onAdd: () => void | Promise; onClose: () => void }) { +function ChapterPreview({ + projectId, + unit, + chapterNumber, + loading, + loadError, + adding, + onAdd, + onClose, +}: { + projectId: string; + unit: ProjectUnit; + chapterNumber: number; + loading: boolean; + loadError: boolean; + adding: boolean; + onAdd: () => void | Promise; + onClose: () => void; +}) { const text = htmlToPlainText(unit.sourceText); return ( -
-
第 {chapterNumber.toLocaleString("zh-CN")} 章

{unit.title}

-

{loading ? "正在读取本章正文…" : loadError ? "正文读取失败,请稍后重试" : text || "本章还没有正文"}

{!loading && !loadError ?
{text.length.toLocaleString("zh-CN")} 字
: null}
-
打开编辑器
+
+
+
+
第 {chapterNumber.toLocaleString("zh-CN")} 章
+

{unit.title}

+
+ +
+
+

{loading ? "正在读取本章正文…" : loadError ? "正文读取失败,请稍后重试" : text || "本章还没有正文"}

+ {!loading && !loadError ?
{text.length.toLocaleString("zh-CN")} 字
: null} +
+
+ + + 打开编辑器 + + +
); } diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 21ae93779..98eebfe78 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -31,15 +31,12 @@ export function ModelPicker({ config, value, onChange, capability, className, fu const [open, setOpen] = useState(false); const menuRef = useRef(null); const triggerRef = useRef(null); - const options = useMemo( - () => { - const filtered = selectableModelsByCapability(config, capability); - const current = value?.trim(); - const currentIncluded = current ? filtered.includes(current) : true; - return Array.from(new Set([...filtered, ...(!currentIncluded && current ? [current] : [])].filter((model): model is string => Boolean(model)))); - }, - [capability, config, value], - ); + const options = useMemo(() => { + const filtered = selectableModelsByCapability(config, capability); + const current = value?.trim(); + const currentIncluded = current ? filtered.includes(current) : true; + return Array.from(new Set([...filtered, ...(!currentIncluded && current ? [current] : [])].filter((model): model is string => Boolean(model)))); + }, [capability, config, value]); const optionGroups = useMemo(() => { const channelGroups = config.channels .map((channel) => ({ @@ -115,7 +112,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} > - {creationVariant ?
选择模型{current ? {modelDisplayName(config, current)} : null}
: null} - {optionGroups.length ? optionGroups.map((group) => ( -
-
- {group.label} - {group.scope} -
-
- {group.models.map((model) => { - const selected = model === current; - return ( - - ); - })} -
-
- )) :
{emptyModelLabel(config, capability)}
} + {creationVariant ? ( +
+ 选择模型 + {current ? {modelDisplayName(config, current)} : null} +
+ ) : null} + {optionGroups.length ? ( + optionGroups.map((group) => ( +
+
+ {group.label} + + {group.scope} + +
+
+ {group.models.map((model) => { + const selected = model === current; + return ( + + ); + })} +
+
+ )) + ) : ( +
+ {emptyModelLabel(config, capability)} +
+ )}
); return ( -
event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - > +
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}> - - {current ? creationVariant ? modelDisplayName(config, current) : modelOptionLabel(config, current) : placeholder} + + + + {current ? (creationVariant ? modelDisplayName(config, current) : modelOptionLabel(config, current)) : placeholder} {showSelectedPrice && creditsEnabled ? : null}
- + setTaskDetail(null)} + superResolveNode={superResolveNode} + onCloseSuperResolve={() => setSuperResolveNodeId(null)} + previewNode={previewNode} + onClosePreview={() => setPreviewNodeId(null)} + clearConfirmOpen={clearConfirmOpen} + onCancelClear={() => setClearConfirmOpen(false)} + onConfirmClear={clearCanvas} + /> + + + handleProjectAssetsInsert(payloads, projectAssetInsertPosition)} + /> + {codexCompactAgent && !assistantMounted ? ( + + ) : null} + + ); } diff --git a/web/src/services/api/announcements.ts b/web/src/services/api/announcements.ts index e1d1432f6..4eee6ee01 100644 --- a/web/src/services/api/announcements.ts +++ b/web/src/services/api/announcements.ts @@ -1,6 +1,4 @@ -import axios from "axios"; - -import type { BackendEnvelope } from "@/services/api/task-center"; +import { apiClient, request } from "@/services/api/request"; export type AnnouncementLevel = "info" | "success" | "warning" | "critical"; export type AnnouncementStatus = "active" | "closed"; @@ -30,13 +28,7 @@ export type AdminAnnouncementListParams = { limit?: number; }; -const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true }); - -async function request(promise: Promise<{ data: BackendEnvelope }>) { - const response = await promise; - if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败"); - return response.data.data; -} +const api = apiClient; export function getAnnouncementFeed() { return request(api.get("/announcements")); diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index 48213ce3e..5a670ea0c 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -1,12 +1,11 @@ -import axios from "axios"; - import type { ModelChannel } from "@/stores/use-config-store"; import type { CreditLedgerEntry } from "@/services/api/wallet"; import type { GenerationTask, TaskStatus } from "@/services/api/task-center"; import type { CanvasDrawingEngineSetting } from "@/lib/canvas/canvas-drawing-engine"; import type { FeatureAvailability } from "@/stores/use-user-store"; +import { apiClient, request } from "@/services/api/request"; -const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true }); +const api = apiClient; export type LocalUser = { id: string; @@ -325,18 +324,6 @@ export type RuntimePolicySetting = { updatedAt?: string; }; -type BackendEnvelope = { code: number; data: T; msg: string }; - -async function request(promise: Promise<{ data: BackendEnvelope }>) { - try { - const response = await promise; - if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败"); - return response.data.data; - } catch (error) { - if (axios.isAxiosError>(error)) throw new Error(error.response?.data?.msg || error.message || "请求失败"); - throw error; - } -} export function getAuthSettings() { return request<{ firstUser: boolean; registrationEnabled: boolean; linuxdoEnabled: boolean; emailEnabled: boolean; emailCodeRequired: boolean }>(api.get("/auth/settings")); diff --git a/web/src/services/api/canvas-share.ts b/web/src/services/api/canvas-share.ts index be9551745..8a5973b5d 100644 --- a/web/src/services/api/canvas-share.ts +++ b/web/src/services/api/canvas-share.ts @@ -1,9 +1,7 @@ -import axios from "axios"; - -import type { BackendEnvelope } from "@/services/api/task-center"; import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; +import { apiClient, request } from "@/services/api/request"; -const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true }); +const api = apiClient; export type CanvasShareStatus = { enabled: boolean; @@ -17,12 +15,6 @@ export type PublicCanvasShare = { expiresAt?: string; }; -async function request(promise: Promise<{ data: BackendEnvelope }>) { - const response = await promise; - if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败"); - return response.data.data; -} - export function getCanvasShare(projectId: string) { return request<{ share: CanvasShareStatus }>(api.get(`/canvas-projects/${encodeURIComponent(projectId)}/share`)); } diff --git a/web/src/services/api/projects.ts b/web/src/services/api/projects.ts index 404e0914e..f179831e9 100644 --- a/web/src/services/api/projects.ts +++ b/web/src/services/api/projects.ts @@ -1,8 +1,6 @@ -import axios from "axios"; +import { apiClient, request } from "@/services/api/request"; -import type { BackendEnvelope } from "@/services/api/task-center"; - -const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true }); +const api = apiClient; export type Project = { id: string; @@ -167,16 +165,6 @@ export type ProjectDetail = { assetCandidates: ProjectAssetCandidate[]; }; -async function request(promise: Promise<{ data: BackendEnvelope }>) { - try { - const response = await promise; - if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败"); - return response.data.data; - } catch (error) { - if (axios.isAxiosError>(error)) throw new Error(error.response?.data?.msg || error.message || "请求失败"); - throw error; - } -} export function listProjects() { return request<{ projects: ProjectSummary[] }>(api.get("/projects")); diff --git a/web/src/services/api/request.ts b/web/src/services/api/request.ts index 9375bfe18..b11c67d04 100644 --- a/web/src/services/api/request.ts +++ b/web/src/services/api/request.ts @@ -1,5 +1,30 @@ +import axios from "axios"; + export type ApiParams = Record; +export type BackendEnvelope = { + code: number; + data: T; + msg: string; +}; + +// 所有后端 JSON 请求共用同一实例,避免认证、Base URL 和错误语义在模块间漂移。 +export const apiBaseURL = import.meta.env.VITE_CANVAS_BACKEND_URL || "/api"; +export const apiClient = axios.create({ baseURL: apiBaseURL, withCredentials: true }); + +export async function request(promise: Promise<{ data: BackendEnvelope }>) { + try { + const response = await promise; + if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败"); + return response.data.data; + } catch (error) { + if (axios.isAxiosError>(error)) { + throw new Error(error.response?.data?.msg || error.message || "请求失败"); + } + throw error; + } +} + export function compactApiParams(params: ApiParams) { return Object.fromEntries(Object.entries(params).filter(([, value]) => value !== "" && value !== undefined && (!Array.isArray(value) || value.length > 0))) as ApiParams; } diff --git a/web/src/services/api/resources.ts b/web/src/services/api/resources.ts index d89c4f2b2..60d154873 100644 --- a/web/src/services/api/resources.ts +++ b/web/src/services/api/resources.ts @@ -1,7 +1,6 @@ -import axios from "axios"; - import { getActiveUserScope } from "@/lib/user-scope"; -import type { BackendEnvelope } from "@/services/api/task-center"; +import axios from "axios"; +import { apiBaseURL, apiClient, request, type BackendEnvelope } from "@/services/api/request"; export type RemoteResource = { id: string; @@ -41,18 +40,11 @@ export type UserOSSSettingInput = Pick(); const resourceRequests = new Map>(); const missingResourceIds = new Set(); -async function request(promise: Promise<{ data: BackendEnvelope }>) { - const response = await promise; - if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败"); - return response.data.data; -} - export function resourceStorageKey(id: string) { return `resource:${id}`; } diff --git a/web/src/services/api/skills.ts b/web/src/services/api/skills.ts index 4ac0af125..29b3e1e80 100644 --- a/web/src/services/api/skills.ts +++ b/web/src/services/api/skills.ts @@ -1,9 +1,7 @@ -import axios from "axios"; - import { compactApiParams, serializeApiParams, type ApiParams } from "@/services/api/request"; -import type { BackendEnvelope } from "@/services/api/task-center"; +import { apiClient, request } from "@/services/api/request"; -const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true }); +const api = apiClient; export type SkillSort = "popular" | "new" | "updated"; export type SkillScope = "public" | "mine" | "created" | "favorites"; @@ -73,16 +71,6 @@ export type SkillMutationInput = { extra_info: string; }; -async function request(promise: Promise<{ data: BackendEnvelope }>) { - try { - const response = await promise; - if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败"); - return response.data.data; - } catch (error) { - if (axios.isAxiosError>(error)) throw new Error(error.response?.data?.msg || error.message || "请求失败"); - throw error; - } -} export function listSkills(input: ListSkillsInput = {}) { const params = serializeApiParams(compactApiParams(input as ApiParams)); diff --git a/web/src/services/api/task-center.ts b/web/src/services/api/task-center.ts index 5b5253ad8..502971db2 100644 --- a/web/src/services/api/task-center.ts +++ b/web/src/services/api/task-center.ts @@ -1,18 +1,13 @@ -import axios from "axios"; - import { generationErrorMessage } from "@/lib/generation-error"; +import { apiClient, request, type BackendEnvelope } from "@/services/api/request"; + +export type { BackendEnvelope } from "@/services/api/request"; export type TaskStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled"; export type TaskBillingStatus = "reserved" | "running" | "settled" | "refunded" | "uncertain"; export type ProviderCancelStatus = "requested" | "confirmed" | "uncertain"; export type AgentSessionStatus = "active" | "completed" | "failed"; -export type BackendEnvelope = { - code: number; - data: T; - msg: string; -}; - export type GenerationTask = { id: string; sessionId?: string; @@ -116,7 +111,7 @@ export type SessionFile = { sessionId: string; fileName: string; mimeType: string; - size: number; + size: number; createdAt: string; }; @@ -157,30 +152,17 @@ export type CreateTaskInput = { input?: Record; }; -const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true }); - -async function request(promise: Promise<{ data: BackendEnvelope }>) { - try { - const response = await promise; - if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败"); - return response.data.data; - } catch (error) { - if (axios.isAxiosError>(error)) { - throw new Error(error.response?.data?.msg || error.message || "请求失败"); - } - throw error; - } -} +const api = apiClient; export function createAgentSession(input: CreateSessionInput) { - return request(api.post("/create_session", input)).then((detail) => { + return request(api.post("/sessions", input)).then((detail) => { detail.tasks.forEach((task) => notifyCanvasTaskCreated(task)); return detail; }); } export function queryAgentSession(id: string) { - return request(api.get(`/query_session/${encodeURIComponent(id)}`)); + return request(api.get(`/sessions/${encodeURIComponent(id)}`)); } export function agentSessionFailureMessage(detail: AgentSessionDetail, fallback = "后端影视 Agent 会话失败") { @@ -196,14 +178,14 @@ export function agentSessionFailureMessage(detail: AgentSessionDetail, fallback } export function downloadSessionResults(id: string) { - return request(api.get(`/download_results/${encodeURIComponent(id)}`)); + return request(api.get(`/sessions/${encodeURIComponent(id)}/results`)); } export function uploadAgentFile(sessionId: string, file: File) { const formData = new FormData(); formData.append("sessionId", sessionId); formData.append("file", file); - return request(api.post("/upload_file", formData)); + return request(api.post("/files", formData)); } export function createGenerationTask(input: CreateTaskInput) { diff --git a/web/src/services/api/user-data.ts b/web/src/services/api/user-data.ts index 8c7b6c1c0..7ac4c8be2 100644 --- a/web/src/services/api/user-data.ts +++ b/web/src/services/api/user-data.ts @@ -1,10 +1,8 @@ -import axios from "axios"; - -import type { BackendEnvelope } from "@/services/api/task-center"; import type { Asset } from "@/stores/use-asset-store"; import type { CanvasProject } from "@/stores/canvas/use-canvas-store"; +import { apiClient, request } from "@/services/api/request"; -const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true }); +const api = apiClient; export type RemoteUserDataSummary = { id: string; @@ -14,16 +12,6 @@ export type RemoteUserDataSummary = { updatedAt: string; }; -async function request(promise: Promise<{ data: BackendEnvelope }>) { - try { - const response = await promise; - if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败"); - return response.data.data; - } catch (error) { - if (axios.isAxiosError>(error)) throw new Error(error.response?.data?.msg || error.message || "请求失败"); - throw error; - } -} export function listRemoteAssets() { return request<{ assets: RemoteUserDataSummary[] }>(api.get("/assets")); diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index 12c12fa2c..a771d8d5f 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -1,19 +1,6 @@ -import axios from "axios"; +import { apiClient, request } from "@/services/api/request"; -const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true }); - -type BackendEnvelope = { code: number; data: T; msg: string }; - -async function request(promise: Promise<{ data: BackendEnvelope }>) { - try { - const response = await promise; - if (response.data.code !== 0) throw new Error(response.data.msg || "请求失败"); - return response.data.data; - } catch (error) { - if (axios.isAxiosError>(error)) throw new Error(error.response?.data?.msg || error.message || "请求失败"); - throw error; - } -} +const api = apiClient; export type CreditAccount = { userId: string; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 7339d8ed9..bb2d47d4f 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -88,6 +88,9 @@ --r-2xl: 16px; --r-3xl: 20px; --r-4xl: 24px; + --r-tight: 15px; + --r-dock-compact: 13px; + --r-wide-tight: 22px; --r-5xl: 32px; --r-full: 9999px; @@ -116,6 +119,7 @@ --z-popover: 200; /* L10 弹出层 */ --z-toast: 500; /* L11 通知 */ --z-tooltip: 1000; /* L12 提示 */ + --z-loader: 1200; /* 全屏加载遮罩 */ --z-max: 2000; /* L13 最高(紧急通知)*/ /* --- 6b. Canvas 浮层定位 inset(画布安全区,对应审计 D2/P0-2)--- */ @@ -516,6 +520,9 @@ --dock-shadow: var(--shadow-lg); --dock-item-radius: var(--r-md); /* 8px */ --dock-item-radius-labeled: var(--r-lg); /* 12px */ + --dock-radius-tight: var(--r-tight); + --dock-radius-compact: var(--r-dock-compact); + --dock-tooltip-z: 140; --dock-precision-width: var(--space-12); --dock-z-popover: 30; /* dock 子面板在 toolbar 根 stacking context 内的层级(见 ADR-001)*/ @@ -557,6 +564,9 @@ --panel-padding: var(--space-4); /* 16px */ --panel-shadow: var(--shadow-lg); --panel-section-gap: var(--space-3); /* 12px */ + --panel-width-compact: 320px; + --panel-radius-wide-tight: var(--r-wide-tight); + --loader-z: var(--z-loader); /* Director workbench / Sequencer */ --director-sequencer-min-height: 180px; diff --git a/web/test/canvas-drawing-engine.test.ts b/web/test/canvas-drawing-engine.test.ts index 932bdb2eb..d2d7fbe73 100644 --- a/web/test/canvas-drawing-engine.test.ts +++ b/web/test/canvas-drawing-engine.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "bun:test"; import { drawingEngineForNode } from "../src/lib/canvas/canvas-drawing-engine"; import { summarizeCanvasDrawing } from "../src/lib/canvas/canvas-drawing-storage";