refactor(*): 代码质量治理 - 收敛接口客户端、模型职责与异步计费巡检
This commit is contained in:
48
.github/workflows/quality.yml
vendored
Normal file
48
.github/workflows/quality.yml
vendored
Normal file
@@ -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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/ddcat-ai/open-ai-canvas"><img src="https://img.shields.io/github/stars/ddcat-ai/open-ai-canvas?style=flat-square&logo=github" alt="GitHub stars"></a>
|
||||
<a href="VERSION"><img src="https://img.shields.io/badge/version-v1.0.34-2563eb?style=flat-square" alt="Version"></a>
|
||||
<a href="VERSION"><img src="https://img.shields.io/badge/version-v1.0.43-2563eb?style=flat-square" alt="Version"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-f97316?style=flat-square" alt="License"></a>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
94
backend/internal/model/models_channel.go
Normal file
94
backend/internal/model/models_channel.go
Normal file
@@ -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"`
|
||||
}
|
||||
116
backend/internal/model/models_finance.go
Normal file
116
backend/internal/model/models_finance.go
Normal file
@@ -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"`
|
||||
}
|
||||
57
backend/internal/model/models_identity.go
Normal file
57
backend/internal/model/models_identity.go
Normal file
@@ -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"`
|
||||
}
|
||||
77
backend/internal/model/models_platform.go
Normal file
77
backend/internal/model/models_platform.go
Normal file
@@ -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"`
|
||||
}
|
||||
279
backend/internal/model/models_project.go
Normal file
279
backend/internal/model/models_project.go
Normal file
@@ -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"`
|
||||
}
|
||||
105
backend/internal/model/models_task.go
Normal file
105
backend/internal/model/models_task.go
Normal file
@@ -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"`
|
||||
}
|
||||
68
backend/internal/repository/billing_review.go
Normal file
68
backend/internal/repository/billing_review.go
Normal file
@@ -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
|
||||
}
|
||||
44
backend/internal/service/billing_review.go
Normal file
44
backend/internal/service/billing_review.go
Normal file
@@ -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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
85
backend/internal/service/provider_request_types.go
Normal file
85
backend/internal/service/provider_request_types.go
Normal file
@@ -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"`
|
||||
}
|
||||
253
backend/internal/service/provider_video_options.go
Normal file
253
backend/internal/service/provider_video_options.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
65
backend/internal/service/service_workflow_results.go
Normal file
65
backend/internal/service/service_workflow_results.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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 ."
|
||||
|
||||
@@ -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 (
|
||||
<button type="button" className="h-9 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80" style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={onClick}>
|
||||
<button
|
||||
type="button"
|
||||
className="h-9 cursor-pointer rounded-full border px-2 text-sm transition hover:opacity-80"
|
||||
style={{ background: "transparent", borderColor: selected ? theme.node.text : theme.node.stroke, color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<SelectionToolbar
|
||||
node={node}
|
||||
viewport={viewport}
|
||||
containerRef={containerRef}
|
||||
status={status}
|
||||
faceCount={faces.length}
|
||||
error={error}
|
||||
onManualSelect={beginManualSelection}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<SelectionToolbar node={node} viewport={viewport} containerRef={containerRef} status={status} faceCount={faces.length} error={error} onManualSelect={beginManualSelection} onClose={onClose} />
|
||||
<AnimatePresence>
|
||||
{activeCharacter && (status === "editing" || status === "generating") ? (
|
||||
<EmotionPanelOverlay node={node} viewport={viewport} containerRef={containerRef}>
|
||||
@@ -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<HTMLDivElement | null>;
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
<svg aria-hidden className="pointer-events-none absolute inset-0 size-full" viewBox={`0 0 ${imageWidth} ${imageHeight}`} preserveAspectRatio="none">
|
||||
<defs>
|
||||
<mask id={`emotion-face-mask-${node.id}`}>
|
||||
<rect width={imageWidth} height={imageHeight} fill="white" />
|
||||
{faces.map((face) => <rect key={face.id} x={face.x} y={face.y} width={face.width} height={face.height} rx={Math.min(face.width, face.height) * 0.16} fill="black" />)}
|
||||
{faces.map((face) => (
|
||||
<rect key={face.id} x={face.x} y={face.y} width={face.width} height={face.height} rx={Math.min(face.width, face.height) * 0.16} fill="black" />
|
||||
))}
|
||||
{manualDraft ? <rect x={manualDraft.x} y={manualDraft.y} width={manualDraft.width} height={manualDraft.height} rx={Math.min(manualDraft.width, manualDraft.height) * 0.12} fill="black" /> : null}
|
||||
</mask>
|
||||
</defs>
|
||||
@@ -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 ? <span className="absolute -top-2.5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full px-1.5 py-0.5 text-[var(--fs-micro)] font-semibold text-white" style={{ background: active ? theme.accent.primary : "rgba(20,20,22,.82)" }}>{selected.name}</span> : null}
|
||||
{selected ? (
|
||||
<span
|
||||
className="absolute -top-2.5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full px-1.5 py-0.5 text-[var(--fs-micro)] font-semibold text-white"
|
||||
style={{ background: active ? theme.accent.primary : "rgba(20,20,22,.82)" }}
|
||||
>
|
||||
{selected.name}
|
||||
</span>
|
||||
) : null}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
{manualDraft ? <div className="pointer-events-none absolute rounded-[var(--r-md)] border-2 border-dashed border-white" style={{ left: `${(manualDraft.x / imageWidth) * 100}%`, top: `${(manualDraft.y / imageHeight) * 100}%`, width: `${(manualDraft.width / imageWidth) * 100}%`, height: `${(manualDraft.height / imageHeight) * 100}%`, boxShadow: "0 0 0 3px rgba(255,255,255,.16)" }} /> : null}
|
||||
{manualDraft ? (
|
||||
<div
|
||||
className="pointer-events-none absolute rounded-[var(--r-md)] border-2 border-dashed border-white"
|
||||
style={{
|
||||
left: `${(manualDraft.x / imageWidth) * 100}%`,
|
||||
top: `${(manualDraft.y / imageHeight) * 100}%`,
|
||||
width: `${(manualDraft.width / imageWidth) * 100}%`,
|
||||
height: `${(manualDraft.height / imageHeight) * 100}%`,
|
||||
boxShadow: "0 0 0 3px rgba(255,255,255,.16)",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectionToolbar({ node, viewport, containerRef, status, faceCount, error, onManualSelect, onClose }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; 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<HTMLDivElement | null>;
|
||||
status: WorkspaceStatus;
|
||||
faceCount: number;
|
||||
error: string;
|
||||
onManualSelect: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const reducedMotion = useReducedMotion();
|
||||
const toolbarRef = useRef<HTMLDivElement>(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()}
|
||||
>
|
||||
<div className="flex size-full items-center">
|
||||
<button type="button" aria-label="关闭情绪调节" className="grid size-8 shrink-0 place-items-center rounded-full hover:bg-black/5 dark:hover:bg-white/10" onClick={onClose}><X className="size-4" /></button>
|
||||
<button type="button" aria-label="关闭情绪调节" className="grid size-8 shrink-0 place-items-center rounded-full hover:bg-black/5 dark:hover:bg-white/10" onClick={onClose}>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
<span className="mx-2 h-5 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-full" style={{ background: theme.toolbar.itemHover }}>{status === "detecting" ? <LoaderCircle className="size-4 animate-spin" /> : <ScanFace className="size-4" />}</span>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-full" style={{ background: theme.toolbar.itemHover }}>
|
||||
{status === "detecting" ? <LoaderCircle className="size-4 animate-spin" /> : <ScanFace className="size-4" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate px-2 text-[var(--fs-label)] font-medium leading-none">{label}</span>
|
||||
{status !== "detecting" ? <button type="button" className="flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-[var(--fs-label)] font-medium leading-none transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onManualSelect}><SquareDashedMousePointer className="size-3.5" />手动框选</button> : null}
|
||||
{status !== "detecting" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-2 text-[var(--fs-label)] font-medium leading-none transition hover:bg-black/5 dark:hover:bg-white/10"
|
||||
onClick={onManualSelect}
|
||||
>
|
||||
<SquareDashedMousePointer className="size-3.5" />
|
||||
手动框选
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</SpotlightSurface>
|
||||
);
|
||||
@@ -305,10 +394,20 @@ function SelectionToolbar({ node, viewport, containerRef, status, faceCount, err
|
||||
function EmotionPanelOverlay({ node, viewport, containerRef, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; children: ReactNode }) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useScreenAnchor(panelRef, node, viewport, containerRef, (next, container) => panelScreenRect(node, next, container, panelRef.current));
|
||||
return <div ref={panelRef} data-canvas-no-zoom className="absolute z-[var(--z-modal-overlay)] w-[580px] max-w-[calc(100%_-_24px)]" style={{ left: 12, top: 76 }} onPointerDown={(event) => event.stopPropagation()}>{children}</div>;
|
||||
return (
|
||||
<div ref={panelRef} data-canvas-no-zoom className="absolute z-[var(--z-modal-overlay)] w-[580px] max-w-[calc(100%_-_24px)]" style={{ left: 12, top: 76 }} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useScreenAnchor(ref: RefObject<HTMLElement | null>, node: CanvasNodeData, viewport: ViewportTransform, containerRef: RefObject<HTMLDivElement | null>, resolve: (viewport: ViewportTransform, container: HTMLDivElement) => { left: number; top: number; width?: number; height?: number }) {
|
||||
function useScreenAnchor(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
node: CanvasNodeData,
|
||||
viewport: ViewportTransform,
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
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<HTMLElement | null>, 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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<div className="flex min-w-0 items-center gap-1 px-0.5">
|
||||
{isPortraitTexture ? (
|
||||
<CanvasPortraitTexturePopover
|
||||
value={node.metadata?.portraitTexture}
|
||||
placement={expanded ? "topRight" : "topLeft"}
|
||||
onChange={(portraitTexture) => onConfigChange(node.id, { portraitTexture })}
|
||||
/>
|
||||
<CanvasPortraitTexturePopover value={node.metadata?.portraitTexture} placement={expanded ? "topRight" : "topLeft"} onChange={(portraitTexture) => onConfigChange(node.id, { portraitTexture })} />
|
||||
) : (
|
||||
<div className="flex h-6 min-w-0 items-center gap-1 rounded-md px-1.5 transition-colors hover:bg-white/[.04]" style={{ background: controlSurface }}>
|
||||
<span className="grid size-3.5 shrink-0 place-items-center" style={{ color: monochromeAccent }}>
|
||||
@@ -154,16 +150,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
<span className="truncate text-[var(--fs-tiny)] font-medium">{modeDisplayName(mode)}创作</span>
|
||||
</div>
|
||||
)}
|
||||
{!simpleMode ? (
|
||||
<CanvasPresetPicker
|
||||
mode={mode}
|
||||
skillReferences={skillReferences}
|
||||
open={expanded ? expandedPresetOpen : presetOpen}
|
||||
onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen}
|
||||
onSelect={applyPreset}
|
||||
dense
|
||||
/>
|
||||
) : null}
|
||||
{!simpleMode ? <CanvasPresetPicker mode={mode} skillReferences={skillReferences} open={expanded ? expandedPresetOpen : presetOpen} onOpenChange={expanded ? setExpandedPresetOpen : setPresetOpen} onSelect={applyPreset} dense /> : null}
|
||||
<div className="ml-auto flex shrink-0 items-center justify-end gap-1">
|
||||
{activeReferenceCount ? <ComposerPill theme={theme} borderColor={insetBorder} icon={<Boxes className="size-2.5" />} label={`已连接 ${activeReferenceCount} 个`} /> : null}
|
||||
{!expanded && canExpandPrompt ? (
|
||||
@@ -183,59 +170,86 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderComposerControls = (expanded: boolean) => simpleMode ? (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 p-1" style={{ background: controlsSurface }}>
|
||||
<span className="min-w-0 truncate px-2 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
|
||||
{activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"}
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
className="!inline-flex !h-8 shrink-0 !items-center !gap-1 !rounded-full !px-2.5 !text-[var(--fs-tiny)] !font-medium"
|
||||
danger={isRunning}
|
||||
disabled={isSubmitDisabled}
|
||||
style={{ background: isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent, color: isSubmitDisabled ? theme.node.faint : isRunning ? "#ffffff" : theme.canvas.background, boxShadow: isSubmitDisabled ? "none" : `0 8px 20px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.18)` }}
|
||||
onClick={() => (isRunning ? onStop(node.id) : expanded ? submitExpandedPrompt() : submit())}
|
||||
aria-label={isRunning ? "停止生成" : "生成"}
|
||||
>
|
||||
{isRunning ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}
|
||||
{isRunning ? "停止" : "生成"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center justify-between gap-0.5 p-1" style={{ background: controlsSurface }}>
|
||||
<div className={`${expanded ? "max-w-[320px]" : mode === "image" || mode === "video" ? "max-w-[240px]" : "max-w-[174px]"} min-w-[104px] flex-1`}>
|
||||
<ModelPicker className="!h-7 !w-full !min-w-0 !text-[var(--fs-tiny)] !font-normal [&_img]:!size-3 [&_.lucide]:!size-3" fullWidth config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => navigateToSettings({ continueCreation: true })} showSelectedPrice={false} />
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-1">
|
||||
{mode === "image" ? (
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement={expanded ? "topRight" : "topLeft"}
|
||||
buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
|
||||
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
|
||||
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
|
||||
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
|
||||
/>
|
||||
) : mode === "video" ? (
|
||||
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-7 !w-[144px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
) : mode === "audio" ? (
|
||||
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
|
||||
) : null}
|
||||
{creditsEnabled ? <GenerationCostBadge credits={credits} theme={theme} /> : null}
|
||||
const renderComposerControls = (expanded: boolean) =>
|
||||
simpleMode ? (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 p-1" style={{ background: controlsSurface }}>
|
||||
<span className="min-w-0 truncate px-2 text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>
|
||||
{activeReferenceCount ? `已连接 ${activeReferenceCount} 个素材` : "将使用默认模型与参数"}
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
className="!inline-flex !h-8 !w-8 shrink-0 !items-center !justify-center !rounded-full !border !p-0 transition hover:!-translate-y-px hover:!brightness-110 motion-reduce:hover:!translate-y-0"
|
||||
className="!inline-flex !h-8 shrink-0 !items-center !gap-1 !rounded-full !px-2.5 !text-[var(--fs-tiny)] !font-medium"
|
||||
danger={isRunning}
|
||||
disabled={isSubmitDisabled}
|
||||
style={{ background: isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent, borderColor: isSubmitDisabled ? insetBorder : monochromeAccent, color: isSubmitDisabled ? theme.node.faint : theme.canvas.background, boxShadow: isSubmitDisabled ? "none" : `0 8px 20px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.18)` }}
|
||||
style={{
|
||||
background: isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent,
|
||||
color: isSubmitDisabled ? theme.node.faint : isRunning ? "#ffffff" : theme.canvas.background,
|
||||
boxShadow: isSubmitDisabled ? "none" : `0 8px 20px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.18)`,
|
||||
}}
|
||||
onClick={() => (isRunning ? onStop(node.id) : expanded ? submitExpandedPrompt() : submit())}
|
||||
aria-label={isRunning ? "停止生成" : "生成"}
|
||||
>
|
||||
{isRunning ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}
|
||||
{isRunning ? "停止" : "生成"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
) : (
|
||||
<div className="flex min-w-0 items-center justify-between gap-0.5 p-1" style={{ background: controlsSurface }}>
|
||||
<div className={`${expanded ? "max-w-[var(--panel-width-compact)]" : mode === "image" || mode === "video" ? "max-w-[240px]" : "max-w-[174px]"} min-w-[104px] flex-1`}>
|
||||
<ModelPicker
|
||||
className="!h-7 !w-full !min-w-0 !text-[var(--fs-tiny)] !font-normal [&_img]:!size-3 [&_.lucide]:!size-3"
|
||||
fullWidth
|
||||
config={config}
|
||||
value={config.model}
|
||||
onChange={(model) => onConfigChange(node.id, { model })}
|
||||
capability={mode}
|
||||
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
|
||||
showSelectedPrice={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-1">
|
||||
{mode === "image" ? (
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement={expanded ? "topRight" : "topLeft"}
|
||||
buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
|
||||
onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })}
|
||||
onMissingConfig={() => navigateToSettings({ continueCreation: true })}
|
||||
onOpenChange={expanded ? undefined : onImageSettingsOpenChange}
|
||||
/>
|
||||
) : mode === "video" ? (
|
||||
<CanvasVideoSettingsPopover
|
||||
config={config}
|
||||
buttonClassName="!h-7 !w-[144px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
|
||||
onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))}
|
||||
/>
|
||||
) : mode === "audio" ? (
|
||||
<CanvasAudioSettingsPopover
|
||||
config={config}
|
||||
buttonClassName="!h-7 !w-[146px] !justify-start !rounded-full !border-0 !px-2.5 !text-[var(--fs-tiny)] !font-normal !shadow-none [&>span]:min-w-0 [&_.lucide]:!size-3"
|
||||
onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))}
|
||||
/>
|
||||
) : null}
|
||||
{creditsEnabled ? <GenerationCostBadge credits={credits} theme={theme} /> : null}
|
||||
<Button
|
||||
type="text"
|
||||
className="!inline-flex !h-8 !w-8 shrink-0 !items-center !justify-center !rounded-full !border !p-0 transition hover:!-translate-y-px hover:!brightness-110 motion-reduce:hover:!translate-y-0"
|
||||
danger={isRunning}
|
||||
disabled={isSubmitDisabled}
|
||||
style={{
|
||||
background: isSubmitDisabled ? theme.toolbar.itemHover : isRunning ? theme.accent.danger : monochromeAccent,
|
||||
borderColor: isSubmitDisabled ? insetBorder : monochromeAccent,
|
||||
color: isSubmitDisabled ? theme.node.faint : theme.canvas.background,
|
||||
boxShadow: isSubmitDisabled ? "none" : `0 8px 20px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.18)`,
|
||||
}}
|
||||
onClick={() => (isRunning ? onStop(node.id) : expanded ? submitExpandedPrompt() : submit())}
|
||||
aria-label={isRunning ? "停止生成" : "生成"}
|
||||
>
|
||||
{isRunning ? <Square className="size-2.5 fill-current" /> : <ArrowUp className="size-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -297,15 +311,18 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
centered
|
||||
width={760}
|
||||
destroyOnHidden
|
||||
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 } }}
|
||||
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 },
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col gap-2.5 p-3" style={{ color: theme.node.text }}>
|
||||
<div className="shrink-0 pr-8">{renderComposerHeader(true)}</div>
|
||||
<div
|
||||
className="flex min-h-[240px] flex-1 flex-col overflow-hidden rounded-xl outline-none ring-0 focus-within:outline-none focus-within:ring-0"
|
||||
style={{ background: composerSurface, boxShadow: composerShadow }}
|
||||
>
|
||||
<div className="flex min-h-[240px] flex-1 flex-col overflow-hidden rounded-xl outline-none ring-0 focus-within:outline-none focus-within:ring-0" style={{ background: composerSurface, boxShadow: composerShadow }}>
|
||||
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />
|
||||
<CanvasResourceMentionTextarea
|
||||
value={prompt}
|
||||
@@ -332,10 +349,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
|
||||
function ComposerPill({ theme, borderColor, icon, label }: { theme: CanvasTheme; borderColor: string; icon: ReactNode; label: string }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md border px-1.5 text-[var(--fs-micro)] font-medium transition hover:brightness-125"
|
||||
style={{ background: theme.canvas.background, borderColor, color: theme.node.activeStroke }}
|
||||
>
|
||||
<span className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md border px-1.5 text-[var(--fs-micro)] font-medium transition hover:brightness-125" style={{ background: theme.canvas.background, borderColor, color: theme.node.activeStroke }}>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
@@ -376,7 +390,9 @@ function ConnectedReferenceShelf({ references, theme, onInsert }: { references:
|
||||
<ReferenceThumbnail reference={reference} />
|
||||
</span>
|
||||
<span className="absolute left-0.5 top-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-[var(--fs-micro)] font-semibold text-white backdrop-blur-sm">{index + 1}</span>
|
||||
<span className="absolute bottom-0.5 right-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm"><AtSign className="size-2" /></span>
|
||||
<span className="absolute bottom-0.5 right-0.5 grid size-3.5 place-items-center rounded-full bg-black/65 text-white backdrop-blur-sm">
|
||||
<AtSign className="size-2" />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -399,7 +415,12 @@ function ReferenceThumbnail({ reference }: { reference: CanvasResourceReference
|
||||
function GenerationCostBadge({ credits, theme }: { credits: number | null; theme: CanvasTheme }) {
|
||||
if (credits === null) return null;
|
||||
return (
|
||||
<span className="canvas-generation-cost-badge inline-flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-[var(--fs-micro)] font-semibold tabular-nums" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }} title={`预计消耗 ${credits.toLocaleString()} 积分`} aria-label={`预计消耗 ${credits.toLocaleString()} 积分`}>
|
||||
<span
|
||||
className="canvas-generation-cost-badge inline-flex h-7 shrink-0 items-center gap-1 rounded-full px-2 text-[var(--fs-micro)] font-semibold tabular-nums"
|
||||
style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}
|
||||
title={`预计消耗 ${credits.toLocaleString()} 积分`}
|
||||
aria-label={`预计消耗 ${credits.toLocaleString()} 积分`}
|
||||
>
|
||||
<CreditSymbol className="text-[var(--fs-label)]" />
|
||||
{credits.toLocaleString()}
|
||||
</span>
|
||||
|
||||
@@ -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 = (
|
||||
<div data-canvas-no-zoom className="canvas-preset-picker-menu w-[320px] max-w-[calc(100vw-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<Input className="canvas-preset-picker-search" variant="borderless" autoFocus allowClear size="small" prefix={<Search className="size-3.5" />} placeholder="搜索预设或已加入技能" value={query} onChange={(event) => setQuery(event.target.value)} />
|
||||
<div data-canvas-no-zoom className="canvas-preset-picker-menu w-[var(--panel-width-compact)] max-w-[calc(100vw-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<Input
|
||||
className="canvas-preset-picker-search"
|
||||
variant="borderless"
|
||||
autoFocus
|
||||
allowClear
|
||||
size="small"
|
||||
prefix={<Search className="size-3.5" />}
|
||||
placeholder="搜索预设或已加入技能"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<div className="thin-scrollbar mt-1 max-h-72 space-y-0.5 overflow-y-auto">
|
||||
{presets.length ? presets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className="canvas-preset-picker-option"
|
||||
onClick={() => {
|
||||
onSelect(preset);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="canvas-preset-picker-option-icon" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}><WandSparkles className="size-3.5" /></span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold" style={{ color: theme.node.text }}>
|
||||
<span className="truncate">{preset.name}</span>
|
||||
<span className="shrink-0 text-[var(--fs-micro)] font-medium" style={{ color: theme.accent.primary }}>{preset.source === "skill" ? "技能" : "预设"}</span>
|
||||
{presets.length ? (
|
||||
presets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className="canvas-preset-picker-option"
|
||||
onClick={() => {
|
||||
onSelect(preset);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="canvas-preset-picker-option-icon" style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}>
|
||||
<WandSparkles className="size-3.5" />
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[var(--fs-tiny)] leading-4" style={{ color: theme.node.muted }}>{preset.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
)) : <div className="py-8 text-center text-xs" style={{ color: theme.node.muted }}>没有匹配的预设</div>}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold" style={{ color: theme.node.text }}>
|
||||
<span className="truncate">{preset.name}</span>
|
||||
<span className="shrink-0 text-[var(--fs-micro)] font-medium" style={{ color: theme.accent.primary }}>
|
||||
{preset.source === "skill" ? "技能" : "预设"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[var(--fs-tiny)] leading-4" style={{ color: theme.node.muted }}>
|
||||
{preset.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="py-8 text-center text-xs" style={{ color: theme.node.muted }}>
|
||||
没有匹配的预设
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={actualOpen} onOpenChange={setOpen} trigger="click" placement="topLeft" arrow={false} content={content} classNames={{ root: "canvas-preset-picker-popover", container: "canvas-composer-popover-surface", content: "canvas-composer-popover-content" }}>
|
||||
<button type="button" className={`canvas-preset-picker-trigger inline-flex shrink-0 items-center justify-center gap-1 rounded-lg transition hover:brightness-110 focus-visible:outline-none ${compact ? "size-6" : dense ? "h-6 px-1.5" : "h-7 px-2"}`} style={{ background: theme.accent.primarySoft, color: theme.accent.primary }} title="打开提示词预设" aria-label="打开提示词预设" aria-expanded={actualOpen}>
|
||||
<Popover
|
||||
open={actualOpen}
|
||||
onOpenChange={setOpen}
|
||||
trigger="click"
|
||||
placement="topLeft"
|
||||
arrow={false}
|
||||
content={content}
|
||||
classNames={{ root: "canvas-preset-picker-popover", container: "canvas-composer-popover-surface", content: "canvas-composer-popover-content" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`canvas-preset-picker-trigger inline-flex shrink-0 items-center justify-center gap-1 rounded-lg transition hover:brightness-110 focus-visible:outline-none ${compact ? "size-6" : dense ? "h-6 px-1.5" : "h-7 px-2"}`}
|
||||
style={{ background: theme.accent.primarySoft, color: theme.accent.primary }}
|
||||
title="打开提示词预设"
|
||||
aria-label="打开提示词预设"
|
||||
aria-expanded={actualOpen}
|
||||
>
|
||||
<WandSparkles className={dense ? "size-3" : "size-3.5"} />
|
||||
{compact ? null : <span className="text-[var(--fs-tiny)] font-semibold">预设</span>}
|
||||
</button>
|
||||
|
||||
@@ -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 (
|
||||
<aside className="relative z-[var(--z-panel)] hidden w-11 shrink-0 flex-col items-center border-r border-border bg-background/94 py-2 backdrop-blur-xl lg:flex">
|
||||
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-foreground/[.06]" title="展开项目侧栏" aria-label="展开项目侧栏" onClick={() => setCollapsed(false)}><ChevronRight className="size-4" /></button>
|
||||
<Link to={`/projects/${projectId}/canvases`} className="mt-2 grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-foreground/[.06]" title="返回项目画布列表"><FolderKanban className="size-4" /></Link>
|
||||
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-foreground/[.06]" title="展开项目侧栏" aria-label="展开项目侧栏" onClick={() => setCollapsed(false)}>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
<Link to={`/projects/${projectId}/canvases`} className="mt-2 grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-foreground/[.06]" title="返回项目画布列表">
|
||||
<FolderKanban className="size-4" />
|
||||
</Link>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -128,29 +116,77 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
return (
|
||||
<aside className="relative z-[var(--z-panel)] hidden w-[var(--canvas-sidebar-width)] shrink-0 flex-col border-r border-border bg-background/94 backdrop-blur-xl lg:flex">
|
||||
<header className="flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border px-2.5">
|
||||
<Link to={`/projects/${projectId}/canvases`} className="flex min-w-0 items-center gap-2 text-xs font-semibold" title="返回项目画布列表"><FolderKanban className="size-3.5 shrink-0" /><span className="truncate">{projectDetail?.project.name || "项目空间"}</span></Link>
|
||||
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md text-foreground/45 hover:bg-foreground/[.06]" title="收起项目侧栏" aria-label="收起项目侧栏" onClick={() => setCollapsed(true)}><ChevronLeft className="size-4" /></button>
|
||||
<Link to={`/projects/${projectId}/canvases`} className="flex min-w-0 items-center gap-2 text-xs font-semibold" title="返回项目画布列表">
|
||||
<FolderKanban className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{projectDetail?.project.name || "项目空间"}</span>
|
||||
</Link>
|
||||
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-md text-foreground/45 hover:bg-foreground/[.06]" title="收起项目侧栏" aria-label="收起项目侧栏" onClick={() => setCollapsed(true)}>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="shrink-0 border-b border-border/70 p-2">
|
||||
<div className="mb-1.5 flex h-5 items-center justify-between px-1 text-[var(--fs-label)] font-medium text-foreground/48"><span className="flex items-center gap-1.5"><Palette className="size-3.5" />项目画风</span><Link to={`/projects/${projectId}/settings`} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-foreground/[.06] hover:text-foreground" title="编辑项目画风" aria-label="编辑项目画风"><Settings2 className="size-3.5" /></Link></div>
|
||||
<button type="button" disabled={!style} onClick={onLocateStyle} className="group flex h-11 w-full items-center gap-2 rounded-md px-1.5 text-left hover:bg-foreground/[.045] disabled:cursor-default disabled:hover:bg-transparent" title={style ? "定位画布中的项目画风节点" : "项目尚未设置画风"}>
|
||||
{style ? <img src={style.imageUrl} alt="" width={48} height={32} className="h-8 w-12 shrink-0 rounded object-cover" /> : <span className="grid h-8 w-12 shrink-0 place-items-center rounded border border-dashed border-border text-foreground/30"><Palette className="size-3.5" /></span>}
|
||||
<span className="min-w-0 flex-1"><span className="block truncate text-xs font-medium">{style?.title || "尚未设置画风"}</span><span className="mt-0.5 block truncate text-[var(--fs-micro)] text-foreground/38">{style ? "随项目设置自动同步" : "前往项目设置选择"}</span></span>
|
||||
<div className="mb-1.5 flex h-5 items-center justify-between px-1 text-[var(--fs-label)] font-medium text-foreground/48">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Palette className="size-3.5" />
|
||||
项目画风
|
||||
</span>
|
||||
<Link to={`/projects/${projectId}/settings`} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-foreground/[.06] hover:text-foreground" title="编辑项目画风" aria-label="编辑项目画风">
|
||||
<Settings2 className="size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!style}
|
||||
onClick={onLocateStyle}
|
||||
className="group flex h-11 w-full items-center gap-2 rounded-md px-1.5 text-left hover:bg-foreground/[.045] disabled:cursor-default disabled:hover:bg-transparent"
|
||||
title={style ? "定位画布中的项目画风节点" : "项目尚未设置画风"}
|
||||
>
|
||||
{style ? (
|
||||
<img src={style.imageUrl} alt="" width={48} height={32} className="h-8 w-12 shrink-0 rounded object-cover" />
|
||||
) : (
|
||||
<span className="grid h-8 w-12 shrink-0 place-items-center rounded border border-dashed border-border text-foreground/30">
|
||||
<Palette className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium">{style?.title || "尚未设置画风"}</span>
|
||||
<span className="mt-0.5 block truncate text-[var(--fs-micro)] text-foreground/38">{style ? "随项目设置自动同步" : "前往项目设置选择"}</span>
|
||||
</span>
|
||||
{style ? <LocateFixed className="size-3.5 shrink-0 text-foreground/25 group-hover:text-[var(--workspace-accent)]" /> : null}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-9 shrink-0 items-center justify-between px-3">
|
||||
<span className="flex items-center gap-1.5 text-[var(--fs-label)] font-medium text-foreground/48"><BookOpenText className="size-3.5" />剧情章节 <span className="tabular-nums text-foreground/32">{orderedUnits.length.toLocaleString("zh-CN")}</span></span>
|
||||
{selectedId ? <button type="button" onClick={revealSelectedChapter} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-foreground/[.06] hover:text-foreground" title="回到当前章节" aria-label="回到当前章节"><Crosshair className="size-3.5" /></button> : null}
|
||||
<span className="flex items-center gap-1.5 text-[var(--fs-label)] font-medium text-foreground/48">
|
||||
<BookOpenText className="size-3.5" />
|
||||
剧情章节 <span className="tabular-nums text-foreground/32">{orderedUnits.length.toLocaleString("zh-CN")}</span>
|
||||
</span>
|
||||
{selectedId ? (
|
||||
<button type="button" onClick={revealSelectedChapter} className="grid size-6 place-items-center rounded text-foreground/35 hover:bg-foreground/[.06] hover:text-foreground" title="回到当前章节" aria-label="回到当前章节">
|
||||
<Crosshair className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 px-2 pb-2">
|
||||
<label className="flex h-8 items-center gap-1.5 rounded-md border border-border/75 bg-foreground/[.025] px-2 focus-within:border-[var(--workspace-accent)] focus-within:ring-2 focus-within:ring-[var(--workspace-accent-soft)]">
|
||||
<Search className="size-3.5 shrink-0 text-foreground/32" />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") selectFirstSearchResult(); }} className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-foreground/28" placeholder="搜索标题或输入章节序号" aria-label="搜索项目章节" />
|
||||
{query ? <button type="button" onClick={() => setQuery("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-foreground/[.06] hover:text-foreground" aria-label="清空章节搜索"><X className="size-3" /></button> : null}
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") selectFirstSearchResult();
|
||||
}}
|
||||
className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-foreground/28"
|
||||
placeholder="搜索标题或输入章节序号"
|
||||
aria-label="搜索项目章节"
|
||||
/>
|
||||
{query ? (
|
||||
<button type="button" onClick={() => setQuery("")} className="grid size-5 shrink-0 place-items-center rounded text-foreground/32 hover:bg-foreground/[.06] hover:text-foreground" aria-label="清空章节搜索">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
) : null}
|
||||
</label>
|
||||
{deferredQuery ? <div className="mt-1 px-0.5 text-[var(--fs-micro)] tabular-nums text-foreground/35">找到 {visibleUnits.length.toLocaleString("zh-CN")} 章</div> : null}
|
||||
</div>
|
||||
@@ -165,38 +201,122 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
const active = unit.id === selectedId;
|
||||
return (
|
||||
<div key={unit.id} className="absolute left-0 top-0 w-full" style={{ height: virtualItem.size, transform: `translateY(${virtualItem.start}px)` }}>
|
||||
<div draggable onDragStart={(event) => { const payload: CanvasProjectChapterPayload = { id: unit.id, projectId, title: unit.title, position: unit.position }; event.dataTransfer.setData(CANVAS_PROJECT_CHAPTER_DND_TYPE, JSON.stringify(payload)); event.dataTransfer.effectAllowed = "copy"; }} className={`group flex h-9 cursor-grab items-center gap-1 rounded-md px-1 text-xs active:cursor-grabbing ${active ? "bg-foreground/[.08]" : "hover:bg-foreground/[.045]"}`}>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
const payload: CanvasProjectChapterPayload = { id: unit.id, projectId, title: unit.title, position: unit.position };
|
||||
event.dataTransfer.setData(CANVAS_PROJECT_CHAPTER_DND_TYPE, JSON.stringify(payload));
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
}}
|
||||
className={`group flex h-9 cursor-grab items-center gap-1 rounded-md px-1 text-xs active:cursor-grabbing ${active ? "bg-foreground/[.08]" : "hover:bg-foreground/[.045]"}`}
|
||||
>
|
||||
<GripVertical className="size-3.5 shrink-0 text-foreground/22" />
|
||||
<button type="button" onClick={() => setSelectedId(unit.id)} className="flex min-w-0 flex-1 items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" aria-label={`预览第 ${index + 1} 章 ${unit.title}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(unit.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]"
|
||||
aria-label={`预览第 ${index + 1} 章 ${unit.title}`}
|
||||
>
|
||||
<span className="w-7 shrink-0 text-[var(--fs-micro)] tabular-nums text-foreground/34">{String(index + 1).padStart(Math.max(2, String(orderedUnits.length).length), "0")}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/68">{unit.title}</span>
|
||||
</button>
|
||||
<button type="button" disabled={Boolean(addingId)} className="grid size-6 shrink-0 place-items-center rounded text-foreground/30 opacity-0 hover:bg-[var(--workspace-accent-soft)] hover:text-[var(--workspace-accent)] group-hover:opacity-100 focus-visible:opacity-100 disabled:cursor-wait" title="添加到画布中央" aria-label={`添加${unit.title}到画布`} onClick={() => void insertChapter(unit)}>{addingId === unit.id ? <LoaderCircle className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(addingId)}
|
||||
className="grid size-6 shrink-0 place-items-center rounded text-foreground/30 opacity-0 hover:bg-[var(--workspace-accent-soft)] hover:text-[var(--workspace-accent)] group-hover:opacity-100 focus-visible:opacity-100 disabled:cursor-wait"
|
||||
title="添加到画布中央"
|
||||
aria-label={`添加${unit.title}到画布`}
|
||||
onClick={() => void insertChapter(unit)}
|
||||
>
|
||||
{addingId === unit.id ? <LoaderCircle className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <div className="px-2 py-8 text-center text-xs text-foreground/40">{orderedUnits.length ? "没有匹配的章节" : "暂无章节"}</div>}
|
||||
) : (
|
||||
<div className="px-2 py-8 text-center text-xs text-foreground/40">{orderedUnits.length ? "没有匹配的章节" : "暂无章节"}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shrink-0 border-t border-border/70 p-2">
|
||||
<button type="button" onClick={onOpenAssets} className="flex h-9 w-full items-center gap-2 rounded-md border border-border/80 px-2 text-xs text-foreground/65 hover:border-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)]"><Images className="size-3.5 text-foreground/42" /><span className="flex-1 text-left">引用项目资产</span><span className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[var(--fs-micro)] tabular-nums text-foreground/45">{mediaAssetCount.toLocaleString("zh-CN")}</span></button>
|
||||
<button type="button" onClick={onOpenAssets} className="flex h-9 w-full items-center gap-2 rounded-md border border-border/80 px-2 text-xs text-foreground/65 hover:border-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)]">
|
||||
<Images className="size-3.5 text-foreground/42" />
|
||||
<span className="flex-1 text-left">引用项目资产</span>
|
||||
<span className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[var(--fs-micro)] tabular-nums text-foreground/45">{mediaAssetCount.toLocaleString("zh-CN")}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{selectedUnit ? <ChapterPreview projectId={projectId} unit={selectedUnitQuery.data?.unit || selectedUnit} chapterNumber={orderedUnits.findIndex((unit) => 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 ? (
|
||||
<ChapterPreview
|
||||
projectId={projectId}
|
||||
unit={selectedUnitQuery.data?.unit || selectedUnit}
|
||||
chapterNumber={orderedUnits.findIndex((unit) => 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}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
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<void>; 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<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const text = htmlToPlainText(unit.sourceText);
|
||||
return (
|
||||
<section className="absolute bottom-20 left-[calc(100%+8px)] top-14 z-[var(--z-panel-floating)] flex w-[320px] flex-col overflow-hidden rounded-lg border border-border/85 bg-background/[.96] shadow-[0_18px_48px_rgba(0,0,0,.22)] backdrop-blur-xl" aria-label={`第 ${chapterNumber} 章预览`}>
|
||||
<header className="flex items-start gap-2 border-b border-border/70 px-3 py-2.5"><div className="min-w-0 flex-1"><div className="text-[var(--fs-tiny)] tabular-nums text-foreground/38">第 {chapterNumber.toLocaleString("zh-CN")} 章</div><h2 className="mt-0.5 truncate text-sm font-semibold">{unit.title}</h2></div><button type="button" onClick={onClose} className="grid size-6 shrink-0 place-items-center rounded text-foreground/38 hover:bg-foreground/[.06] hover:text-foreground" aria-label="关闭章节预览"><X className="size-3.5" /></button></header>
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-3"><p className="whitespace-pre-wrap text-xs leading-5 text-foreground/58">{loading ? "正在读取本章正文…" : loadError ? "正文读取失败,请稍后重试" : text || "本章还没有正文"}</p>{!loading && !loadError ? <div className="mt-2 text-[var(--fs-tiny)] tabular-nums text-foreground/35">{text.length.toLocaleString("zh-CN")} 字</div> : null}</div>
|
||||
<footer className="flex shrink-0 items-center justify-between border-t border-border/70 px-2 py-2"><button type="button" disabled={adding} onClick={() => void onAdd()} className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)] disabled:cursor-wait">{adding ? <LoaderCircle className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}添加到画布</button><Link to={`/projects/${projectId}/chapters/${unit.id}`} className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-foreground/62 hover:bg-foreground/[.06] hover:text-foreground">打开编辑器<ArrowUpRight className="size-3.5" /></Link></footer>
|
||||
<section
|
||||
className="absolute bottom-20 left-[calc(100%+8px)] top-14 z-[var(--z-panel-floating)] flex w-[var(--panel-width-compact)] flex-col overflow-hidden rounded-lg border border-border/85 bg-background/[.96] shadow-[0_18px_48px_rgba(0,0,0,.22)] backdrop-blur-xl"
|
||||
aria-label={`第 ${chapterNumber} 章预览`}
|
||||
>
|
||||
<header className="flex items-start gap-2 border-b border-border/70 px-3 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[var(--fs-tiny)] tabular-nums text-foreground/38">第 {chapterNumber.toLocaleString("zh-CN")} 章</div>
|
||||
<h2 className="mt-0.5 truncate text-sm font-semibold">{unit.title}</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="grid size-6 shrink-0 place-items-center rounded text-foreground/38 hover:bg-foreground/[.06] hover:text-foreground" aria-label="关闭章节预览">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-3 py-3">
|
||||
<p className="whitespace-pre-wrap text-xs leading-5 text-foreground/58">{loading ? "正在读取本章正文…" : loadError ? "正文读取失败,请稍后重试" : text || "本章还没有正文"}</p>
|
||||
{!loading && !loadError ? <div className="mt-2 text-[var(--fs-tiny)] tabular-nums text-foreground/35">{text.length.toLocaleString("zh-CN")} 字</div> : null}
|
||||
</div>
|
||||
<footer className="flex shrink-0 items-center justify-between border-t border-border/70 px-2 py-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={adding}
|
||||
onClick={() => void onAdd()}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)] disabled:cursor-wait"
|
||||
>
|
||||
{adding ? <LoaderCircle className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}添加到画布
|
||||
</button>
|
||||
<Link to={`/projects/${projectId}/chapters/${unit.id}`} className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs text-foreground/62 hover:bg-foreground/[.06] hover:text-foreground">
|
||||
打开编辑器
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Link>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,15 +31,12 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(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
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-canvas-no-zoom
|
||||
className={cn("canvas-model-picker-menu max-w-[calc(100vw-24px)]", creationVariant ? "creation-model-picker-menu w-[360px]" : "w-[320px]")}
|
||||
className={cn("canvas-model-picker-menu max-w-[calc(100vw-24px)]", creationVariant ? "creation-model-picker-menu w-[360px]" : "w-[var(--panel-width-compact)]")}
|
||||
style={{ background: theme.node.panel, color: theme.node.text }}
|
||||
role="listbox"
|
||||
aria-label={placeholder}
|
||||
@@ -123,47 +120,56 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{creationVariant ? <div className="creation-model-picker-heading"><span>选择模型</span>{current ? <strong>{modelDisplayName(config, current)}</strong> : null}</div> : null}
|
||||
{optionGroups.length ? optionGroups.map((group) => (
|
||||
<section key={group.key} className="canvas-model-picker-group min-w-0 overflow-hidden">
|
||||
<div className="canvas-model-picker-group-label" style={{ color: theme.node.muted }}>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<span className="shrink-0" style={{ color: theme.node.muted }}>{group.scope}</span>
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-1">
|
||||
{group.models.map((model) => {
|
||||
const selected = model === current;
|
||||
return (
|
||||
<button
|
||||
key={model}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className="canvas-model-picker-option"
|
||||
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
|
||||
onClick={() => {
|
||||
onChange(model);
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}}
|
||||
>
|
||||
<ModelLabel config={config} model={model} capability={capability} theme={theme} creationVariant={creationVariant} showPrice={creditsEnabled} />
|
||||
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)) : <div className="canvas-model-picker-empty" style={{ color: theme.node.muted }}>{emptyModelLabel(config, capability)}</div>}
|
||||
{creationVariant ? (
|
||||
<div className="creation-model-picker-heading">
|
||||
<span>选择模型</span>
|
||||
{current ? <strong>{modelDisplayName(config, current)}</strong> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{optionGroups.length ? (
|
||||
optionGroups.map((group) => (
|
||||
<section key={group.key} className="canvas-model-picker-group min-w-0 overflow-hidden">
|
||||
<div className="canvas-model-picker-group-label" style={{ color: theme.node.muted }}>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<span className="shrink-0" style={{ color: theme.node.muted }}>
|
||||
{group.scope}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-1">
|
||||
{group.models.map((model) => {
|
||||
const selected = model === current;
|
||||
return (
|
||||
<button
|
||||
key={model}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className="canvas-model-picker-option"
|
||||
style={{ background: selected ? theme.toolbar.activeBg : "transparent", color: theme.node.text }}
|
||||
onClick={() => {
|
||||
onChange(model);
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
}}
|
||||
>
|
||||
<ModelLabel config={config} model={model} capability={capability} theme={theme} creationVariant={creationVariant} showPrice={creditsEnabled} />
|
||||
{selected ? <Check className="canvas-model-picker-option-check" style={{ color: theme.node.activeStroke }} /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className="canvas-model-picker-empty" style={{ color: theme.node.muted }}>
|
||||
{emptyModelLabel(config, capability)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(fullWidth ? "w-full min-w-0" : "w-fit max-w-full")}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={cn(fullWidth ? "w-full min-w-0" : "w-fit max-w-full")} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setPickerOpen}
|
||||
@@ -188,8 +194,10 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
onKeyDown={handleTriggerKeyDown}
|
||||
>
|
||||
<span className="canvas-model-picker-label flex min-w-0 items-center gap-1.5">
|
||||
<span className="canvas-model-picker-trigger-icon" style={{ background: theme.toolbar.itemHover }}><ModelIcon model={current} /></span>
|
||||
<span className="min-w-0 flex-1 truncate">{current ? creationVariant ? modelDisplayName(config, current) : modelOptionLabel(config, current) : placeholder}</span>
|
||||
<span className="canvas-model-picker-trigger-icon" style={{ background: theme.toolbar.itemHover }}>
|
||||
<ModelIcon model={current} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{current ? (creationVariant ? modelDisplayName(config, current) : modelOptionLabel(config, current)) : placeholder}</span>
|
||||
{showSelectedPrice && creditsEnabled ? <ModelPrice price={currentPrice} compact /> : null}
|
||||
</span>
|
||||
<ChevronDown className={cn("canvas-model-picker-chevron", open && "is-open")} aria-hidden="true" />
|
||||
@@ -205,7 +213,21 @@ function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
|
||||
}
|
||||
|
||||
function ModelLabel({ config, model, capability, theme, creationVariant, showPrice }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; creationVariant: boolean; showPrice: boolean }) {
|
||||
function ModelLabel({
|
||||
config,
|
||||
model,
|
||||
capability,
|
||||
theme,
|
||||
creationVariant,
|
||||
showPrice,
|
||||
}: {
|
||||
config: AiConfig;
|
||||
model: string;
|
||||
capability?: ModelCapability;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
creationVariant: boolean;
|
||||
showPrice: boolean;
|
||||
}) {
|
||||
const meta = modelMenuMeta(model, capability);
|
||||
const videoProfile = capability === "video" ? modelCapabilityConfigFor(config, model).video : undefined;
|
||||
const capabilitySummary = videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description;
|
||||
@@ -216,10 +238,16 @@ function ModelLabel({ config, model, capability, theme, creationVariant, showPri
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{modelDisplayName(config, model)}</span>
|
||||
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }} title={capabilitySummary}>{capabilitySummary}</span>
|
||||
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }} title={capabilitySummary}>
|
||||
{capabilitySummary}
|
||||
</span>
|
||||
</span>
|
||||
{showPrice ? <ModelPrice price={modelMenuPrice(config, model)} /> : null}
|
||||
{!creationVariant && meta.time ? <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{meta.time}</span> : null}
|
||||
{!creationVariant && meta.time ? (
|
||||
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>
|
||||
{meta.time}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -280,16 +308,7 @@ export function resolveModelIcon(model: string) {
|
||||
const name = model.toLowerCase();
|
||||
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
|
||||
// Flow2API 短名:Nano Banana / Imagen / Veo / Omni 均属 Google Gemini 系。
|
||||
if (
|
||||
name.includes("gemini") ||
|
||||
name.includes("google") ||
|
||||
name.includes("nano banana") ||
|
||||
name.includes("nanobanana") ||
|
||||
name.includes("imagen") ||
|
||||
name.includes("veo") ||
|
||||
name.includes("omni flash") ||
|
||||
name.includes("omni-flash")
|
||||
) {
|
||||
if (name.includes("gemini") || name.includes("google") || name.includes("nano banana") || name.includes("nanobanana") || name.includes("imagen") || name.includes("veo") || name.includes("omni flash") || name.includes("omni-flash")) {
|
||||
return "/icons/gemini.svg";
|
||||
}
|
||||
if (name.includes("gpt") || name.includes("openai") || name.includes("dall-e") || name.includes("dalle")) return "/icons/openai.svg";
|
||||
|
||||
@@ -88,15 +88,27 @@ export const FloatingDock = forwardRef<HTMLDivElement, FloatingDockProps>(functi
|
||||
embedded ? "shadow-none" : "border backdrop-blur-2xl",
|
||||
showLabels
|
||||
? embedded
|
||||
? size === "compact" ? "h-9 gap-0.5 px-0.5" : "h-10 gap-0.5 px-0.5"
|
||||
: size === "compact" ? "h-10 gap-0.5 rounded-[13px] px-1.5" : "h-11 gap-0.5 rounded-[15px] px-2"
|
||||
? size === "compact"
|
||||
? "h-9 gap-0.5 px-0.5"
|
||||
: "h-10 gap-0.5 px-0.5"
|
||||
: size === "compact"
|
||||
? "h-10 gap-0.5 rounded-[var(--dock-radius-compact)] px-1.5"
|
||||
: "h-11 gap-0.5 rounded-[var(--dock-radius-tight)] px-2"
|
||||
: coarsePointer
|
||||
? embedded
|
||||
? size === "compact" ? "h-10 gap-1 px-0.5" : "h-11 gap-1 px-0.5"
|
||||
: size === "compact" ? "h-11 gap-1 rounded-[15px] px-1.5 pb-1" : "h-12 gap-1 rounded-[var(--panel-radius)] px-2 pb-1"
|
||||
: embedded
|
||||
? size === "compact" ? "h-8 gap-0.5 px-0.5 pb-0.5" : "h-9 gap-0.5 px-0.5 pb-0.5"
|
||||
: size === "compact" ? "h-8 gap-0.5 rounded-[var(--r-lg)] px-1 pb-1" : "h-10 gap-0.5 rounded-[var(--dock-radius)] px-1.5 pb-1",
|
||||
? embedded
|
||||
? size === "compact"
|
||||
? "h-10 gap-1 px-0.5"
|
||||
: "h-11 gap-1 px-0.5"
|
||||
: size === "compact"
|
||||
? "h-11 gap-1 rounded-[var(--dock-radius-tight)] px-1.5 pb-1"
|
||||
: "h-12 gap-1 rounded-[var(--panel-radius)] px-2 pb-1"
|
||||
: embedded
|
||||
? size === "compact"
|
||||
? "h-8 gap-0.5 px-0.5 pb-0.5"
|
||||
: "h-9 gap-0.5 px-0.5 pb-0.5"
|
||||
: size === "compact"
|
||||
? "h-8 gap-0.5 rounded-[var(--r-lg)] px-1 pb-1"
|
||||
: "h-10 gap-0.5 rounded-[var(--dock-radius)] px-1.5 pb-1",
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
@@ -186,7 +198,11 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
|
||||
aria-expanded={command.expands ? command.active || undefined : undefined}
|
||||
aria-pressed={command.expands ? undefined : command.active || undefined}
|
||||
disabled={command.disabled}
|
||||
className={cn("aceternity-dock-command is-labeled group inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--dock-item-radius)] border-0 px-2.5 outline-none", command.active && "is-active", command.danger && "is-danger")}
|
||||
className={cn(
|
||||
"aceternity-dock-command is-labeled group inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-[var(--dock-item-radius)] border-0 px-2.5 outline-none",
|
||||
command.active && "is-active",
|
||||
command.danger && "is-danger",
|
||||
)}
|
||||
whileTap={!command.disabled ? { scale: 0.96 } : undefined}
|
||||
transition={aceternityMotion.spring.dock}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
@@ -232,7 +248,10 @@ function DockCommandButton({ command, mouseX, metrics, motionEnabled, compact, s
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 4, scale: 0.96, transition: { duration: 0 } }}
|
||||
transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }}
|
||||
className={cn("aceternity-dock-tooltip pointer-events-none absolute left-1/2 z-[140] -translate-x-1/2 whitespace-nowrap border font-medium shadow-xl backdrop-blur-xl", compact ? "-top-7 rounded-md px-1.5 py-0.5 text-[var(--fs-micro)]" : "-top-8 rounded-md px-2 py-1 text-[var(--fs-tiny)]")}
|
||||
className={cn(
|
||||
"aceternity-dock-tooltip pointer-events-none absolute left-1/2 z-[var(--dock-tooltip-z)] -translate-x-1/2 whitespace-nowrap border font-medium shadow-xl backdrop-blur-xl",
|
||||
compact ? "-top-7 rounded-md px-1.5 py-0.5 text-[var(--fs-micro)]" : "-top-8 rounded-md px-2 py-1 text-[var(--fs-tiny)]",
|
||||
)}
|
||||
>
|
||||
{command.label}
|
||||
</motion.span>
|
||||
|
||||
@@ -20,9 +20,12 @@ export function FullScreenLoader({ label = "正在恢复创作空间", detail =
|
||||
initial={reducedMotion ? false : { opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: aceternityMotion.duration.state, ease: aceternityMotion.easing.enter }}
|
||||
className={cn("fixed inset-0 z-[1200] grid min-h-dvh place-items-center overflow-hidden bg-[#f5f6f8] px-6 text-stone-950 dark:bg-[#0b0c0f] dark:text-white", className)}
|
||||
className={cn("fixed inset-0 z-[var(--loader-z)] grid min-h-dvh place-items-center overflow-hidden bg-[#f5f6f8] px-6 text-stone-950 dark:bg-[#0b0c0f] dark:text-white", className)}
|
||||
>
|
||||
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(rgba(15,23,42,.045)_1px,transparent_1px),linear-gradient(90deg,rgba(15,23,42,.045)_1px,transparent_1px)] bg-[size:44px_44px] dark:bg-[linear-gradient(rgba(255,255,255,.035)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.035)_1px,transparent_1px)]" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-[linear-gradient(rgba(15,23,42,.045)_1px,transparent_1px),linear-gradient(90deg,rgba(15,23,42,.045)_1px,transparent_1px)] bg-[size:44px_44px] dark:bg-[linear-gradient(rgba(255,255,255,.035)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.035)_1px,transparent_1px)]"
|
||||
/>
|
||||
<div aria-hidden className="absolute inset-0 overflow-hidden opacity-70">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<motion.span
|
||||
@@ -46,7 +49,7 @@ export function FullScreenLoader({ label = "正在恢复创作空间", detail =
|
||||
/>
|
||||
<motion.span
|
||||
aria-hidden
|
||||
className="absolute inset-3 rounded-[22px] border border-amber-500/30 dark:border-amber-300/22"
|
||||
className="absolute inset-3 rounded-[var(--panel-radius-wide-tight)] border border-amber-500/30 dark:border-amber-300/22"
|
||||
animate={reducedMotion ? undefined : { rotate: -360 }}
|
||||
transition={{ duration: 4.1, repeat: Infinity, ease: "linear" }}
|
||||
style={{ clipPath: "polygon(32% 0, 100% 0, 100% 100%, 98% 100%, 98% 2px, 32% 2px)" }}
|
||||
|
||||
@@ -29,23 +29,42 @@ export function CanvasUploadStatusToast({ status, theme }: { status: CanvasUploa
|
||||
initial={{ opacity: 0, x: 22, scale: 0.94 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
transition={aceternityMotion.spring.panel}
|
||||
className="pointer-events-none absolute right-4 top-20 z-[var(--z-panel-floating)] w-[320px] overflow-hidden rounded-[22px] border px-4 py-4 backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.elevated, borderColor: status.error ? `${theme.accent.danger}66` : status.done ? "rgba(34,197,94,.4)" : theme.spatial.glowStrong, color: theme.node.text, boxShadow: `0 24px 72px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.14)` }}
|
||||
className="pointer-events-none absolute right-4 top-20 z-[var(--z-panel-floating)] w-[var(--panel-width-compact)] overflow-hidden rounded-[var(--panel-radius-wide-tight)] border px-4 py-4 backdrop-blur-2xl"
|
||||
style={{
|
||||
background: theme.spatial.elevated,
|
||||
borderColor: status.error ? `${theme.accent.danger}66` : status.done ? "rgba(34,197,94,.4)" : theme.spatial.glowStrong,
|
||||
color: theme.node.text,
|
||||
boxShadow: `0 24px 72px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.14)`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-px" style={{ background: `linear-gradient(90deg, transparent, ${accent}, transparent)` }} />
|
||||
<div className="flex items-start gap-3">
|
||||
<motion.span animate={status.done || status.error ? { scale: [0.8, 1.08, 1] } : { y: [0, -2, 0] }} transition={status.done || status.error ? { duration: aceternityMotion.duration.panel } : { duration: 1.5, repeat: Number.POSITIVE_INFINITY }} className="mt-0.5 grid size-9 shrink-0 place-items-center rounded-[var(--dock-radius)] border" style={{ background: theme.spatial.surface, borderColor: `${accent}44`, color: accent }}>
|
||||
<motion.span
|
||||
animate={status.done || status.error ? { scale: [0.8, 1.08, 1] } : { y: [0, -2, 0] }}
|
||||
transition={status.done || status.error ? { duration: aceternityMotion.duration.panel } : { duration: 1.5, repeat: Number.POSITIVE_INFINITY }}
|
||||
className="mt-0.5 grid size-9 shrink-0 place-items-center rounded-[var(--dock-radius)] border"
|
||||
style={{ background: theme.spatial.surface, borderColor: `${accent}44`, color: accent }}
|
||||
>
|
||||
{status.error ? <TriangleAlert className="size-4" /> : status.done ? <CheckCircle2 className="size-4" /> : <CloudUpload className="size-4" />}
|
||||
</motion.span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">{status.title}</span>
|
||||
<span className="mt-1 block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }}>{status.detail}</span>
|
||||
<span className="mt-1 block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
|
||||
{status.detail}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border px-2 py-1 text-[var(--fs-tiny)] font-semibold tabular-nums" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.node.muted }}>
|
||||
{status.step}/{status.total}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border px-2 py-1 text-[var(--fs-tiny)] font-semibold tabular-nums" style={{ background: theme.spatial.surface, borderColor: theme.toolbar.border, color: theme.node.muted }}>{status.step}/{status.total}</span>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-1.5">
|
||||
{Array.from({ length: status.total }, (_, index) => (
|
||||
<motion.span key={index} animate={{ opacity: index < status.step ? 1 : 0.24, scaleX: index < status.step ? 1 : 0.88 }} className="h-1.5 min-w-0 flex-1 rounded-full" style={{ background: index < status.step ? accent : theme.toolbar.itemHover, transformOrigin: "left" }} />
|
||||
<motion.span
|
||||
key={index}
|
||||
animate={{ opacity: index < status.step ? 1 : 0.24, scaleX: index < status.step ? 1 : 0.88 }}
|
||||
className="h-1.5 min-w-0 flex-1 rounded-full"
|
||||
style={{ background: index < status.step ? accent : theme.toolbar.itemHover, transformOrigin: "left" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="sr-only">{progress}%</span>
|
||||
@@ -54,23 +73,39 @@ export function CanvasUploadStatusToast({ status, theme }: { status: CanvasUploa
|
||||
}
|
||||
|
||||
export function TaskDetailItem({ label, value }: { label: string; value: string }) {
|
||||
return <div className="min-w-0"><div className="text-[var(--fs-tiny)] opacity-50">{label}</div><div className="mt-1 truncate text-xs font-medium" title={value}>{value}</div></div>;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div className="text-[var(--fs-tiny)] opacity-50">{label}</div>
|
||||
<div className="mt-1 truncate text-xs font-medium" title={value}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasMergeStatusToast({ progress, theme }: { progress: MergeVideoProgress; theme: CanvasTheme }) {
|
||||
const detail = progress.phase === "loading" ? "加载视频工具" : progress.phase === "reading" ? "读取选中视频" : "正在编码合并成片";
|
||||
const percent = Math.max(0, Math.min(100, Math.round(progress.progress)));
|
||||
return (
|
||||
<div data-canvas-no-zoom aria-live="polite" className="pointer-events-none absolute right-4 top-[164px] z-[var(--z-panel-floating)] w-[292px] overflow-hidden rounded-2xl border px-3 py-3 shadow-lg backdrop-blur-xl" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
|
||||
<div
|
||||
data-canvas-no-zoom
|
||||
aria-live="polite"
|
||||
className="pointer-events-none absolute right-4 top-[164px] z-[var(--z-panel-floating)] w-[292px] overflow-hidden rounded-2xl border px-3 py-3 shadow-lg backdrop-blur-xl"
|
||||
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="mt-0.5 grid size-7 shrink-0 place-items-center rounded-xl" style={{ background: theme.toolbar.itemHover, color: theme.node.activeStroke }}>
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold">合并成片</span>
|
||||
<span className="mt-0.5 block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }}>{detail}</span>
|
||||
<span className="mt-0.5 block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
|
||||
{detail}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-[var(--fs-tiny)] tabular-nums" style={{ color: theme.node.faint }}>
|
||||
{percent}%
|
||||
</span>
|
||||
<span className="shrink-0 text-[var(--fs-tiny)] tabular-nums" style={{ color: theme.node.faint }}>{percent}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1 overflow-hidden rounded-full" style={{ background: theme.toolbar.itemHover }}>
|
||||
<div className="h-full rounded-full transition-all duration-300" style={{ width: `${percent}%`, background: theme.node.activeStroke }} />
|
||||
@@ -81,15 +116,37 @@ export function CanvasMergeStatusToast({ progress, theme }: { progress: MergeVid
|
||||
|
||||
export function CanvasAgentChangeToast({ change, theme, onView, onUndo, onClose }: { change: CanvasAgentChange; theme: CanvasTheme; onView: () => void; onUndo: () => void; onClose: () => void }) {
|
||||
return (
|
||||
<div data-canvas-no-zoom aria-live="polite" className="absolute bottom-20 right-4 z-[var(--z-panel-floating)] w-[320px] rounded-lg border p-3 shadow-lg backdrop-blur-xl" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}>
|
||||
<div
|
||||
data-canvas-no-zoom
|
||||
aria-live="polite"
|
||||
className="absolute bottom-20 right-4 z-[var(--z-panel-floating)] w-[var(--panel-width-compact)] rounded-lg border p-3 shadow-lg backdrop-blur-xl"
|
||||
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover, color: theme.node.activeStroke }}><span className="size-2 rounded-full bg-current" /></span>
|
||||
<span className="min-w-0 flex-1"><span className="block text-xs font-semibold">Agent 已写回画布</span><span className="mt-0.5 block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }}>{change.summary} · 可撤销最近 {change.undoCount} 批</span></span>
|
||||
<button type="button" className="grid size-7 place-items-center rounded-md opacity-55 transition hover:opacity-100" onClick={onClose} aria-label="关闭"><X className="size-3.5" /></button>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover, color: theme.node.activeStroke }}>
|
||||
<span className="size-2 rounded-full bg-current" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-semibold">Agent 已写回画布</span>
|
||||
<span className="mt-0.5 block truncate text-[var(--fs-label)]" style={{ color: theme.node.muted }}>
|
||||
{change.summary} · 可撤销最近 {change.undoCount} 批
|
||||
</span>
|
||||
</span>
|
||||
<button type="button" className="grid size-7 place-items-center rounded-md opacity-55 transition hover:opacity-100" onClick={onClose} aria-label="关闭">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-end gap-1.5">
|
||||
{change.nodeIds.length ? <button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onView}><Eye className="size-3.5" />查看本次改动</button> : null}
|
||||
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onUndo}><RotateCcw className="size-3.5" />撤销本次操作</button>
|
||||
{change.nodeIds.length ? (
|
||||
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onView}>
|
||||
<Eye className="size-3.5" />
|
||||
查看本次改动
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition hover:bg-black/5 dark:hover:bg-white/10" onClick={onUndo}>
|
||||
<RotateCcw className="size-3.5" />
|
||||
撤销本次操作
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
38
web/src/pages/canvas/canvas-refresh-shell.tsx
Normal file
38
web/src/pages/canvas/canvas-refresh-shell.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
export function CanvasRefreshShell() {
|
||||
return (
|
||||
<main className="relative h-full min-h-0 overflow-hidden bg-background text-foreground">
|
||||
<div
|
||||
className="absolute inset-0 opacity-60"
|
||||
style={{
|
||||
backgroundImage: "radial-gradient(circle, var(--border) 1px, transparent 1px)",
|
||||
backgroundSize: "28px 28px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute bottom-5 left-1/2 z-[var(--z-toolbar)] flex h-14 -translate-x-1/2 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur"
|
||||
style={{ background: "var(--background)", borderColor: "var(--border)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div key={index} className="size-8 rounded-md bg-current opacity-10" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-24 left-6 z-[var(--z-toolbar)] h-40 w-[240px] rounded-lg border shadow-2xl backdrop-blur-sm" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
|
||||
<div className="absolute left-7 top-7 h-5 w-12 rounded-sm bg-current opacity-10" />
|
||||
<div className="absolute left-28 top-16 h-6 w-16 rounded-sm bg-current opacity-10" />
|
||||
<div className="absolute bottom-7 left-16 h-8 w-20 rounded-sm bg-current opacity-10" />
|
||||
<div className="absolute inset-5 rounded border border-current opacity-15" />
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-5 left-5 z-[var(--z-toolbar)] flex h-14 w-[260px] items-center gap-2 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
|
||||
<div className="size-8 rounded-md bg-current opacity-10" />
|
||||
<div className="size-8 rounded-md bg-current opacity-10" />
|
||||
<div className="h-1 flex-1 rounded-full bg-current opacity-10" />
|
||||
<div className="h-4 w-10 rounded bg-current opacity-10" />
|
||||
<div className="size-8 rounded-md bg-current opacity-10" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
|
||||
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<AnnouncementFeed>(api.get("/announcements"));
|
||||
|
||||
@@ -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<T> = { code: number; data: T; msg: string };
|
||||
|
||||
async function request<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
|
||||
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<BackendEnvelope<unknown>>(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"));
|
||||
|
||||
@@ -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<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
|
||||
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`));
|
||||
}
|
||||
|
||||
@@ -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<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
|
||||
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<BackendEnvelope<unknown>>(error)) throw new Error(error.response?.data?.msg || error.message || "请求失败");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function listProjects() {
|
||||
return request<{ projects: ProjectSummary[] }>(api.get("/projects"));
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
import axios from "axios";
|
||||
|
||||
export type ApiParams = Record<string, string | string[] | number | number[] | undefined>;
|
||||
|
||||
export type BackendEnvelope<T> = {
|
||||
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<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
|
||||
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<BackendEnvelope<unknown>>(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;
|
||||
}
|
||||
|
||||
@@ -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<UserOSSSetting, "enabled" | "provider" |
|
||||
accessKeySecret?: string;
|
||||
};
|
||||
|
||||
const apiBaseURL = import.meta.env.VITE_CANVAS_BACKEND_URL || "/api";
|
||||
const api = axios.create({ baseURL: apiBaseURL, withCredentials: true });
|
||||
const api = apiClient;
|
||||
const resourceCache = new Map<string, RemoteResource>();
|
||||
const resourceRequests = new Map<string, Promise<RemoteResource>>();
|
||||
const missingResourceIds = new Set<string>();
|
||||
|
||||
async function request<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
|
||||
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<BackendEnvelope<unknown>>(error)) throw new Error(error.response?.data?.msg || error.message || "请求失败");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function listSkills(input: ListSkillsInput = {}) {
|
||||
const params = serializeApiParams(compactApiParams(input as ApiParams));
|
||||
|
||||
@@ -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<T> = {
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
const api = axios.create({ baseURL: import.meta.env.VITE_CANVAS_BACKEND_URL || "/api", withCredentials: true });
|
||||
|
||||
async function request<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
|
||||
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<BackendEnvelope<unknown>>(error)) {
|
||||
throw new Error(error.response?.data?.msg || error.message || "请求失败");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const api = apiClient;
|
||||
|
||||
export function createAgentSession(input: CreateSessionInput) {
|
||||
return request<AgentSessionDetail>(api.post("/create_session", input)).then((detail) => {
|
||||
return request<AgentSessionDetail>(api.post("/sessions", input)).then((detail) => {
|
||||
detail.tasks.forEach((task) => notifyCanvasTaskCreated(task));
|
||||
return detail;
|
||||
});
|
||||
}
|
||||
|
||||
export function queryAgentSession(id: string) {
|
||||
return request<AgentSessionDetail>(api.get(`/query_session/${encodeURIComponent(id)}`));
|
||||
return request<AgentSessionDetail>(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<TaskResult[]>(api.get(`/download_results/${encodeURIComponent(id)}`));
|
||||
return request<TaskResult[]>(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<SessionFile>(api.post("/upload_file", formData));
|
||||
return request<SessionFile>(api.post("/files", formData));
|
||||
}
|
||||
|
||||
export function createGenerationTask(input: CreateTaskInput) {
|
||||
|
||||
@@ -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<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
|
||||
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<BackendEnvelope<unknown>>(error)) throw new Error(error.response?.data?.msg || error.message || "请求失败");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function listRemoteAssets() {
|
||||
return request<{ assets: RemoteUserDataSummary[] }>(api.get("/assets"));
|
||||
|
||||
@@ -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<T> = { code: number; data: T; msg: string };
|
||||
|
||||
async function request<T>(promise: Promise<{ data: BackendEnvelope<T> }>) {
|
||||
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<BackendEnvelope<unknown>>(error)) throw new Error(error.response?.data?.msg || error.message || "请求失败");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const api = apiClient;
|
||||
|
||||
export type CreditAccount = {
|
||||
userId: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user