323 lines
12 KiB
Go
323 lines
12 KiB
Go
package app
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
"time"
|
||
|
||
"infinite-canvas/backend/internal/kernel"
|
||
"infinite-canvas/backend/internal/model"
|
||
)
|
||
|
||
// TaskSummary 是任务列表/会话详情的读模型,不直接复用数据库 Task,避免把
|
||
// 渠道模型、供应线路和受保护输入泄露到普通用户接口。
|
||
type TaskSummary struct {
|
||
ID string `json:"id"`
|
||
ProjectID string `json:"projectId,omitempty"`
|
||
Type string `json:"type"`
|
||
Status model.TaskStatus `json:"status"`
|
||
Stage string `json:"stage"`
|
||
MediaStage string `json:"mediaStage,omitempty"`
|
||
CanRecoverMedia bool `json:"canRecoverMedia,omitempty"`
|
||
Progress int `json:"progress"`
|
||
Prompt string `json:"prompt"`
|
||
Operation string `json:"operation,omitempty"`
|
||
Provider string `json:"provider,omitempty"`
|
||
Model string `json:"model,omitempty"`
|
||
ProviderRequestID string `json:"providerRequestId,omitempty"`
|
||
ProviderCancelStatus model.ProviderCancelStatus `json:"providerCancelStatus,omitempty"`
|
||
ProviderCancelError string `json:"providerCancelError,omitempty"`
|
||
ProviderCancelAttempts int `json:"providerCancelAttempts,omitempty"`
|
||
ProviderCancelRequestedAt *time.Time `json:"providerCancelRequestedAt,omitempty"`
|
||
ProviderCancelledAt *time.Time `json:"providerCancelledAt,omitempty"`
|
||
ErrorCode string `json:"errorCode,omitempty"`
|
||
PreviewURL string `json:"previewUrl,omitempty"`
|
||
PreviewKind string `json:"previewKind,omitempty"`
|
||
PreviewPosterURL string `json:"previewPosterUrl,omitempty"`
|
||
Attempts int `json:"attempts"`
|
||
StartedAt *time.Time `json:"startedAt"`
|
||
CompletedAt *time.Time `json:"completedAt"`
|
||
CreatedAt time.Time `json:"createdAt"`
|
||
UpdatedAt time.Time `json:"updatedAt"`
|
||
Billing *TaskBillingSummary `json:"billing,omitempty"`
|
||
ClientContext *TaskClientContext `json:"clientContext,omitempty"`
|
||
}
|
||
|
||
type TaskClientContext struct {
|
||
NodeID string `json:"nodeId,omitempty"`
|
||
ConversationID string `json:"conversationId,omitempty"`
|
||
MessageID string `json:"messageId,omitempty"`
|
||
BatchIndex int `json:"batchIndex,omitempty"`
|
||
BatchCount int `json:"batchCount,omitempty"`
|
||
DomainProjectID string `json:"domainProjectId,omitempty"`
|
||
ChapterID string `json:"chapterId,omitempty"`
|
||
ChapterOperation string `json:"chapterOperation,omitempty"`
|
||
ShotID string `json:"shotId,omitempty"`
|
||
WorkflowStepID string `json:"workflowStepId,omitempty"`
|
||
ArtifactType string `json:"artifactType,omitempty"`
|
||
}
|
||
|
||
type TaskBillingSummary struct {
|
||
AmountMicrocredits int64 `json:"amountMicrocredits"`
|
||
Status model.BillingStatus `json:"status"`
|
||
}
|
||
|
||
func taskSummariesForOutput(tasks []model.Task) []TaskSummary {
|
||
return taskSummariesForOutputWithBilling(tasks, nil)
|
||
}
|
||
|
||
func taskSummariesForOutputWithBilling(tasks []model.Task, orders map[string]model.BillingOrder) []TaskSummary {
|
||
result := make([]TaskSummary, 0, len(tasks))
|
||
for _, task := range tasks {
|
||
summary := taskSummaryForOutput(task)
|
||
if order, ok := orders[task.ID]; ok {
|
||
summary.Billing = &TaskBillingSummary{AmountMicrocredits: order.AmountMicrocredits, Status: order.Status}
|
||
if summary.ProviderRequestID == "" {
|
||
summary.ProviderRequestID = order.ProviderRequestID
|
||
}
|
||
}
|
||
result = append(result, summary)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func taskBillingTaskIDs(tasks []model.Task) []string {
|
||
ids := make([]string, 0, len(tasks))
|
||
seen := map[string]struct{}{}
|
||
for _, task := range tasks {
|
||
if task.BillingOrderID == "" {
|
||
continue
|
||
}
|
||
if _, ok := seen[task.ID]; ok {
|
||
continue
|
||
}
|
||
seen[task.ID] = struct{}{}
|
||
ids = append(ids, task.ID)
|
||
}
|
||
return ids
|
||
}
|
||
|
||
func taskSummaryForOutput(task model.Task) TaskSummary {
|
||
errorCode := ""
|
||
if isContentModerationFailure(task.Error) {
|
||
errorCode = contentModerationErrorCode
|
||
}
|
||
previewURL, previewKind, previewPosterURL := taskMediaPreviewWithPoster(task.ResultJSON, task.Type)
|
||
return TaskSummary{
|
||
ID: task.ID,
|
||
ProjectID: task.ProjectID,
|
||
Type: task.Type,
|
||
Status: task.Status,
|
||
Stage: task.Stage,
|
||
MediaStage: task.MediaStage,
|
||
CanRecoverMedia: task.MediaRecoveryJSON != "" && task.Status == model.TaskStatusFailed,
|
||
Progress: task.Progress,
|
||
Prompt: truncateRunes(task.Prompt, 500),
|
||
Operation: task.Operation,
|
||
Provider: task.Provider,
|
||
Model: task.Model,
|
||
ProviderRequestID: task.ProviderRequestID,
|
||
ProviderCancelStatus: task.ProviderCancelStatus,
|
||
ProviderCancelError: task.ProviderCancelError,
|
||
ProviderCancelAttempts: task.ProviderCancelAttempts,
|
||
ProviderCancelRequestedAt: task.ProviderCancelRequestedAt,
|
||
ProviderCancelledAt: task.ProviderCancelledAt,
|
||
ErrorCode: errorCode,
|
||
PreviewURL: previewURL,
|
||
PreviewKind: previewKind,
|
||
PreviewPosterURL: previewPosterURL,
|
||
Attempts: task.Attempts,
|
||
StartedAt: task.StartedAt,
|
||
CompletedAt: task.CompletedAt,
|
||
CreatedAt: task.CreatedAt,
|
||
UpdatedAt: task.UpdatedAt,
|
||
ClientContext: taskClientContext(task.InputJSON),
|
||
}
|
||
}
|
||
|
||
// 列表只暴露页面恢复所需的非敏感关联 ID,不下发完整任务输入或其他 metadata。
|
||
func taskClientContext(raw string) *TaskClientContext {
|
||
var input struct {
|
||
Metadata struct {
|
||
Source string `json:"source"`
|
||
NodeID string `json:"nodeId"`
|
||
ConversationID string `json:"conversationId"`
|
||
MessageID string `json:"messageId"`
|
||
BatchIndex int `json:"batchIndex"`
|
||
BatchCount int `json:"batchCount"`
|
||
DomainProjectID string `json:"domainProjectId"`
|
||
ChapterID string `json:"chapterId"`
|
||
Operation string `json:"operation"`
|
||
ShotID string `json:"shotId"`
|
||
WorkflowStepID string `json:"workflowStepId"`
|
||
ArtifactType string `json:"artifactType"`
|
||
} `json:"metadata"`
|
||
}
|
||
if json.Unmarshal([]byte(raw), &input) != nil {
|
||
return nil
|
||
}
|
||
metadata := input.Metadata
|
||
context := &TaskClientContext{NodeID: metadata.NodeID}
|
||
if metadata.Source == "create-page" && metadata.ConversationID != "" && metadata.MessageID != "" {
|
||
context.ConversationID = metadata.ConversationID
|
||
context.MessageID = metadata.MessageID
|
||
context.BatchIndex = metadata.BatchIndex
|
||
context.BatchCount = metadata.BatchCount
|
||
return context
|
||
}
|
||
if metadata.ShotID != "" && metadata.WorkflowStepID != "" {
|
||
context.DomainProjectID = metadata.DomainProjectID
|
||
context.ShotID = metadata.ShotID
|
||
context.WorkflowStepID = metadata.WorkflowStepID
|
||
context.ArtifactType = metadata.ArtifactType
|
||
return context
|
||
}
|
||
chapterOperation := ""
|
||
if metadata.Operation == "chapter_character_breakdown" {
|
||
chapterOperation = "characters"
|
||
} else if metadata.Source == "short-drama-chapter-storyboard" {
|
||
chapterOperation = "storyboard"
|
||
}
|
||
if chapterOperation == "" || metadata.DomainProjectID == "" || metadata.ChapterID == "" {
|
||
if context.NodeID == "" {
|
||
return nil
|
||
}
|
||
return context
|
||
}
|
||
context.DomainProjectID = metadata.DomainProjectID
|
||
context.ChapterID = metadata.ChapterID
|
||
context.ChapterOperation = chapterOperation
|
||
return context
|
||
}
|
||
|
||
// 列表只暴露首个可访问媒体地址,避免把完整生成结果和内嵌数据带回前端。
|
||
func taskMediaPreview(raw string, taskType string) (string, string) {
|
||
previewURL, previewKind, _ := taskMediaPreviewWithPoster(raw, taskType)
|
||
return previewURL, previewKind
|
||
}
|
||
|
||
func taskMediaPreviewWithPoster(raw string, taskType string) (string, string, string) {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return "", "", ""
|
||
}
|
||
var payload any
|
||
if json.Unmarshal([]byte(raw), &payload) != nil {
|
||
return "", "", ""
|
||
}
|
||
defaultKind := "image"
|
||
if strings.Contains(strings.ToLower(taskType), "video") {
|
||
defaultKind = "video"
|
||
}
|
||
previewURL, previewKind := findTaskMediaPreview(payload, defaultKind)
|
||
posterURL := findTaskMediaPoster(payload)
|
||
if previewKind == "image" && posterURL == "" {
|
||
posterURL = previewURL
|
||
}
|
||
return previewURL, previewKind, posterURL
|
||
}
|
||
|
||
func findTaskMediaPoster(value any) string {
|
||
switch item := value.(type) {
|
||
case []any:
|
||
for _, child := range item {
|
||
if posterURL := findTaskMediaPoster(child); posterURL != "" {
|
||
return posterURL
|
||
}
|
||
}
|
||
case map[string]any:
|
||
for _, key := range []string{"posterUrl", "posterURL", "thumbnailUrl", "thumbnailURL", "coverUrl", "coverURL", "poster", "thumbnail", "cover"} {
|
||
child, exists := item[key]
|
||
if !exists {
|
||
continue
|
||
}
|
||
if previewURL, previewKind := findTaskMediaPreview(child, "image"); previewURL != "" && previewKind == "image" {
|
||
return previewURL
|
||
}
|
||
}
|
||
for _, child := range item {
|
||
if posterURL := findTaskMediaPoster(child); posterURL != "" {
|
||
return posterURL
|
||
}
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func findTaskMediaPreview(value any, hint string) (string, string) {
|
||
switch item := value.(type) {
|
||
case string:
|
||
text := strings.TrimSpace(item)
|
||
if !isResourceFileURL(text) && !strings.HasPrefix(text, "http://") && !strings.HasPrefix(text, "https://") {
|
||
return "", ""
|
||
}
|
||
kind := hint
|
||
lower := strings.ToLower(text)
|
||
if strings.Contains(lower, ".mp4") || strings.Contains(lower, ".webm") || strings.Contains(lower, ".mov") {
|
||
kind = "video"
|
||
} else if kind != "video" {
|
||
kind = "image"
|
||
}
|
||
return text, kind
|
||
case []any:
|
||
for _, child := range item {
|
||
if previewURL, previewKind := findTaskMediaPreview(child, hint); previewURL != "" {
|
||
return previewURL, previewKind
|
||
}
|
||
}
|
||
case map[string]any:
|
||
for _, key := range []string{"images", "image", "video", "dataUrl", "url", "resultUrl", "outputUrl"} {
|
||
child, exists := item[key]
|
||
if !exists {
|
||
continue
|
||
}
|
||
childHint := hint
|
||
if key == "video" {
|
||
childHint = "video"
|
||
} else if key == "images" || key == "image" {
|
||
childHint = "image"
|
||
}
|
||
if previewURL, previewKind := findTaskMediaPreview(child, childHint); previewURL != "" {
|
||
return previewURL, previewKind
|
||
}
|
||
}
|
||
}
|
||
return "", ""
|
||
}
|
||
|
||
func truncateRunes(value string, limit int) string {
|
||
return kernel.TruncateRunes(value, limit)
|
||
}
|
||
|
||
func taskForOutput(task model.Task) *model.Task {
|
||
task.CanRecoverMedia = task.MediaRecoveryJSON != "" && task.Status == model.TaskStatusFailed
|
||
task.Diagnostic = taskExecutionDiagnostic(&task)
|
||
task.InputJSON = publicTaskInputJSON(task.InputJSON)
|
||
// 普通任务接口只暴露前台模型身份;渠道模型和供应线路属于管理员内部信息。
|
||
task.LogicalModelRevisionID = ""
|
||
task.RouteID = ""
|
||
task.ChannelModelID = ""
|
||
return &task
|
||
}
|
||
|
||
func publicTaskInputJSON(raw string) string {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return ""
|
||
}
|
||
var input map[string]any
|
||
if err := json.Unmarshal([]byte(raw), &input); err != nil {
|
||
return ""
|
||
}
|
||
public := map[string]any{}
|
||
// 任务完成后仍需依靠这些非敏感 ID 恢复项目产物归属;密钥等配置继续被过滤。
|
||
for _, key := range []string{"mode", "metadata", "workflowStepId", "domainProjectId", "assetVersionId", "resourceId", "mediaType", "role"} {
|
||
if value, ok := input[key]; ok {
|
||
public[key] = value
|
||
}
|
||
}
|
||
if len(public) == 0 {
|
||
return ""
|
||
}
|
||
data, _ := json.Marshal(public)
|
||
return string(data)
|
||
}
|