Files
ttstd-infinite-canvas/backend/internal/app/task_output.go

323 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"strings"
"time"
"yingce/backend/internal/kernel"
"yingce/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)
}