refactor(*): 代码质量治理 - 收敛接口客户端、模型职责与异步计费巡检

This commit is contained in:
ddCat
2026-08-06 16:36:51 +08:00
parent b518489fdd
commit e20df8e29b
42 changed files with 3051 additions and 2271 deletions

48
.github/workflows/quality.yml vendored Normal file
View 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
View File

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

View File

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

View File

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

View File

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

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

View 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"`
}

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

View 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()
}
}()
}

View File

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

View 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"`
}

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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