celery 升级优化V2 | 日志调整 | 前端BUG修复
This commit is contained in:
@@ -2,6 +2,7 @@ export interface GenerationStatusLike {
|
|||||||
status?: string | null;
|
status?: string | null;
|
||||||
displayStatus?: string | null;
|
displayStatus?: string | null;
|
||||||
pipelineStage?: string | null;
|
pipelineStage?: string | null;
|
||||||
|
shouldPoll?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GenerationUiColor = 'default' | 'processing' | 'warning' | 'success' | 'error' | 'blue' | 'orange' | 'purple';
|
export type GenerationUiColor = 'default' | 'processing' | 'warning' | 'success' | 'error' | 'blue' | 'orange' | 'purple';
|
||||||
@@ -19,60 +20,42 @@ export interface GenerationUiState {
|
|||||||
isTerminal: boolean;
|
isTerminal: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVE_STATUS_KEYS = new Set(['pending', 'optimizing', 'prompt_optimized', 'generating']);
|
const ACTIVE_STATUS_KEYS = new Set(['pending', 'optimizing', 'generating']);
|
||||||
const ACTIVE_PIPELINE_STAGES = new Set([
|
const ACTIVE_PIPELINE_STAGES = new Set([
|
||||||
'queued', 'preparing', 'creating_provider_task', 'provider_result_staged',
|
'queued', 'preparing', 'creating_provider_task', 'provider_result_staged',
|
||||||
'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading',
|
'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading',
|
||||||
'retry_waiting', 'upscale_queued', 'upscale_processing', 'upscale_polling',
|
'retry_waiting', 'recovery_inconsistent', 'upscale_queued', 'upscale_processing',
|
||||||
'upscale_downloading', 'upscale_finalizing', 'upscale_retry_waiting',
|
'upscale_polling', 'upscale_downloading', 'upscale_finalizing', 'upscale_retry_waiting',
|
||||||
]);
|
]);
|
||||||
const SUCCESS_KEYS = new Set(['completed', 'done']);
|
const SUCCESS_KEYS = new Set(['completed', 'done']);
|
||||||
const FAILURE_KEYS = new Set(['failed', 'timeout', 'download_failed', 'upscale_failed']);
|
const FAILURE_KEYS = new Set(['failed', 'timeout', 'download_failed', 'upscale_failed']);
|
||||||
const TERMINAL_KEYS = new Set([...SUCCESS_KEYS, ...FAILURE_KEYS, 'deleted']);
|
const TERMINAL_KEYS = new Set([...SUCCESS_KEYS, ...FAILURE_KEYS, 'deleted']);
|
||||||
|
|
||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
pending: '待处理',
|
pending: '待处理', optimizing: '提词处理中', prompt_optimized: '待生成', generating: '生成中',
|
||||||
optimizing: '优化中',
|
queued: '已入队', preparing: '准备中', creating_provider_task: '创建供应商任务',
|
||||||
prompt_optimized: '待生成',
|
provider_result_staged: '供应商结果已暂存', waiting_remote: '等待供应商结果', polling: '轮询供应商结果',
|
||||||
generating: '生成中',
|
result_ready: '远程结果已就绪', download_queued: '下载已入队', downloading: '下载中',
|
||||||
queued: '已入队',
|
retry_waiting: '下载等待重试', recovery_inconsistent: '恢复证据异常', upscale_queued: '超分已入队',
|
||||||
preparing: '准备中',
|
upscale_processing: '本地超分处理中', upscale_polling: '轮询远程超分',
|
||||||
creating_provider_task: '创建供应商任务',
|
upscale_downloading: '下载超分结果', upscale_finalizing: '超分结果最终化',
|
||||||
provider_result_staged: '供应商结果已暂存',
|
upscale_retry_waiting: '超分等待重试', completed: '已完成', done: '已完成', timeout: '任务超时',
|
||||||
waiting_remote: '等待供应商结果',
|
download_failed: '下载失败', upscale_failed: '超分失败', failed: '失败', deleted: '已删除',
|
||||||
polling: '轮询供应商结果',
|
|
||||||
result_ready: '远程结果已就绪',
|
|
||||||
download_queued: '下载已入队',
|
|
||||||
downloading: '下载中',
|
|
||||||
retry_waiting: '下载等待重试',
|
|
||||||
upscale_queued: '超分已入队',
|
|
||||||
upscale_processing: '本地超分处理中',
|
|
||||||
upscale_polling: '轮询远程超分',
|
|
||||||
upscale_downloading: '下载超分结果',
|
|
||||||
upscale_finalizing: '超分结果最终化',
|
|
||||||
upscale_retry_waiting: '超分等待重试',
|
|
||||||
completed: '已完成',
|
|
||||||
done: '已完成',
|
|
||||||
timeout: '任务超时',
|
|
||||||
download_failed: '下载失败',
|
|
||||||
upscale_failed: '超分失败',
|
|
||||||
failed: '失败',
|
|
||||||
deleted: '已删除',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLOR_MAP: Record<string, GenerationUiColor> = {
|
const COLOR_MAP: Record<string, GenerationUiColor> = {
|
||||||
pending: 'default', optimizing: 'processing', prompt_optimized: 'processing', generating: 'warning',
|
pending: 'default', optimizing: 'processing', prompt_optimized: 'blue', generating: 'warning',
|
||||||
queued: 'processing', preparing: 'processing', creating_provider_task: 'processing',
|
queued: 'processing', preparing: 'processing', creating_provider_task: 'processing',
|
||||||
provider_result_staged: 'processing', waiting_remote: 'processing', polling: 'processing',
|
provider_result_staged: 'processing', waiting_remote: 'processing', polling: 'processing',
|
||||||
result_ready: 'processing', download_queued: 'processing', downloading: 'processing',
|
result_ready: 'processing', download_queued: 'processing', downloading: 'processing', retry_waiting: 'orange',
|
||||||
retry_waiting: 'orange', upscale_queued: 'purple', upscale_processing: 'purple',
|
recovery_inconsistent: 'orange', upscale_queued: 'purple', upscale_processing: 'purple',
|
||||||
upscale_polling: 'purple', upscale_downloading: 'purple', upscale_finalizing: 'purple',
|
upscale_polling: 'purple', upscale_downloading: 'purple', upscale_finalizing: 'purple',
|
||||||
upscale_retry_waiting: 'orange', completed: 'success', done: 'success', failed: 'error',
|
upscale_retry_waiting: 'orange', completed: 'success', done: 'success', failed: 'error',
|
||||||
timeout: 'error', download_failed: 'error', upscale_failed: 'error', deleted: 'default',
|
timeout: 'error', download_failed: 'error', upscale_failed: 'error', deleted: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
|
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
|
||||||
const firstMatching = (values: string[], keys: Set<string>): string => values.find((value) => keys.has(value)) || '';
|
const firstMatching = (values: string[], keys: Set<string>): string => values.find((item) => keys.has(item)) || '';
|
||||||
|
|
||||||
export const getGenerationStageLabel = (key?: string | null): string => {
|
export const getGenerationStageLabel = (key?: string | null): string => {
|
||||||
const normalized = normalize(key);
|
const normalized = normalize(key);
|
||||||
@@ -88,41 +71,22 @@ export const resolveGenerationUiState = (value: GenerationStatusLike): Generatio
|
|||||||
const status = normalize(value.status);
|
const status = normalize(value.status);
|
||||||
const displayStatus = normalize(value.displayStatus);
|
const displayStatus = normalize(value.displayStatus);
|
||||||
const pipelineStage = normalize(value.pipelineStage);
|
const pipelineStage = normalize(value.pipelineStage);
|
||||||
const values = [displayStatus, status, pipelineStage].filter(Boolean);
|
const values = [pipelineStage, displayStatus, status].filter(Boolean);
|
||||||
const failureKey = FAILURE_KEYS.has(pipelineStage)
|
const failureKey = firstMatching(values, FAILURE_KEYS);
|
||||||
? pipelineStage
|
|
||||||
: firstMatching([displayStatus, status], FAILURE_KEYS);
|
|
||||||
const deletedKey = firstMatching(values, new Set(['deleted']));
|
|
||||||
const successKey = firstMatching(values, SUCCESS_KEYS);
|
const successKey = firstMatching(values, SUCCESS_KEYS);
|
||||||
|
const deletedKey = firstMatching(values, new Set(['deleted']));
|
||||||
const effectiveKey = failureKey
|
const effectiveKey = failureKey || deletedKey || successKey || pipelineStage || displayStatus || status || 'pending';
|
||||||
|| deletedKey
|
|
||||||
|| (ACTIVE_PIPELINE_STAGES.has(pipelineStage) ? pipelineStage : '')
|
|
||||||
|| successKey
|
|
||||||
|| pipelineStage
|
|
||||||
|| displayStatus
|
|
||||||
|| status
|
|
||||||
|| 'pending';
|
|
||||||
|
|
||||||
const isFailure = FAILURE_KEYS.has(effectiveKey);
|
const isFailure = FAILURE_KEYS.has(effectiveKey);
|
||||||
const isSuccess = SUCCESS_KEYS.has(effectiveKey);
|
const isSuccess = SUCCESS_KEYS.has(effectiveKey);
|
||||||
const isActive = !isFailure && !isSuccess && effectiveKey !== 'deleted' && (
|
const isActive = typeof value.shouldPoll === 'boolean'
|
||||||
ACTIVE_PIPELINE_STAGES.has(pipelineStage)
|
? value.shouldPoll
|
||||||
|| ACTIVE_STATUS_KEYS.has(displayStatus)
|
: (!isFailure && !isSuccess && effectiveKey !== 'deleted' && (ACTIVE_PIPELINE_STAGES.has(pipelineStage) || ACTIVE_STATUS_KEYS.has(status) || ACTIVE_STATUS_KEYS.has(displayStatus)));
|
||||||
|| ACTIVE_STATUS_KEYS.has(status)
|
|
||||||
|| ACTIVE_PIPELINE_STAGES.has(effectiveKey)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status, displayStatus, pipelineStage, effectiveKey,
|
||||||
displayStatus,
|
|
||||||
pipelineStage,
|
|
||||||
effectiveKey,
|
|
||||||
label: getGenerationStageLabel(effectiveKey),
|
label: getGenerationStageLabel(effectiveKey),
|
||||||
color: getGenerationStatusColor(effectiveKey),
|
color: getGenerationStatusColor(effectiveKey),
|
||||||
isActive,
|
isActive, isSuccess, isFailure,
|
||||||
isSuccess,
|
|
||||||
isFailure,
|
|
||||||
isTerminal: TERMINAL_KEYS.has(effectiveKey),
|
isTerminal: TERMINAL_KEYS.has(effectiveKey),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,3 +19,5 @@ from app.enums.audio_reference import *
|
|||||||
|
|
||||||
from app.enums.private_portrait import *
|
from app.enums.private_portrait import *
|
||||||
from app.enums.generation_provider import *
|
from app.enums.generation_provider import *
|
||||||
|
|
||||||
|
from app.enums.generation_record import *
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationRecordConfigSourceEnum(StrEnum):
|
||||||
|
"""生成记录配置冻结来源。"""
|
||||||
|
|
||||||
|
PROMPT_OPTIMIZE = "prompt_optimize"
|
||||||
|
LEGACY_GENERATE_FALLBACK = "legacy_generate_fallback"
|
||||||
|
EXISTING_FROZEN_CONFIG = "existing_frozen_config"
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationRecordEventTypeEnum(StrEnum):
|
||||||
|
"""GenerationRecord 用户生成链路事件。"""
|
||||||
|
|
||||||
|
PROMPT_CONFIG_VALIDATE_START = "PROMPT_CONFIG_VALIDATE_START"
|
||||||
|
PROMPT_CONFIG_VALIDATE_SUCCESS = "PROMPT_CONFIG_VALIDATE_SUCCESS"
|
||||||
|
PROMPT_CONFIG_VALIDATE_FAILED = "PROMPT_CONFIG_VALIDATE_FAILED"
|
||||||
|
PROMPT_CONFIG_FREEZE_START = "PROMPT_CONFIG_FREEZE_START"
|
||||||
|
PROMPT_CONFIG_FREEZE_SUCCESS = "PROMPT_CONFIG_FREEZE_SUCCESS"
|
||||||
|
PROMPT_CONFIG_FREEZE_FAILED = "PROMPT_CONFIG_FREEZE_FAILED"
|
||||||
|
LEGACY_CONFIG_FALLBACK_START = "LEGACY_CONFIG_FALLBACK_START"
|
||||||
|
LEGACY_CONFIG_FALLBACK_SUCCESS = "LEGACY_CONFIG_FALLBACK_SUCCESS"
|
||||||
|
LEGACY_CONFIG_FALLBACK_FAILED = "LEGACY_CONFIG_FALLBACK_FAILED"
|
||||||
|
LEGACY_CONFIG_FALLBACK_SKIPPED = "LEGACY_CONFIG_FALLBACK_SKIPPED"
|
||||||
|
GENERATION_SUBMIT_START = "GENERATION_SUBMIT_START"
|
||||||
|
GENERATION_SUBMIT_CONFIG_READY = "GENERATION_SUBMIT_CONFIG_READY"
|
||||||
|
GENERATION_SUBMIT_BILLING_SUCCESS = "GENERATION_SUBMIT_BILLING_SUCCESS"
|
||||||
|
GENERATION_SUBMIT_ENQUEUE_SUCCESS = "GENERATION_SUBMIT_ENQUEUE_SUCCESS"
|
||||||
|
GENERATION_SUBMIT_FAILED = "GENERATION_SUBMIT_FAILED"
|
||||||
@@ -22,6 +22,7 @@ class GenerationRecordPipelineStage(str, Enum):
|
|||||||
DOWNLOAD_QUEUED = "download_queued"
|
DOWNLOAD_QUEUED = "download_queued"
|
||||||
DOWNLOADING = "downloading"
|
DOWNLOADING = "downloading"
|
||||||
RETRY_WAITING = "retry_waiting"
|
RETRY_WAITING = "retry_waiting"
|
||||||
|
RECOVERY_INCONSISTENT = "recovery_inconsistent"
|
||||||
UPSCALE_QUEUED = "upscale_queued"
|
UPSCALE_QUEUED = "upscale_queued"
|
||||||
UPSCALE_PROCESSING = "upscale_processing"
|
UPSCALE_PROCESSING = "upscale_processing"
|
||||||
UPSCALE_POLLING = "upscale_polling"
|
UPSCALE_POLLING = "upscale_polling"
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class ChatGenerationPipelineStage(str, Enum):
|
|||||||
DOWNLOAD_QUEUED = "download_queued"
|
DOWNLOAD_QUEUED = "download_queued"
|
||||||
DOWNLOADING = "downloading"
|
DOWNLOADING = "downloading"
|
||||||
RETRY_WAITING = "retry_waiting"
|
RETRY_WAITING = "retry_waiting"
|
||||||
|
RECOVERY_INCONSISTENT = "recovery_inconsistent"
|
||||||
UPSCALE_QUEUED = "upscale_queued"
|
UPSCALE_QUEUED = "upscale_queued"
|
||||||
UPSCALE_PROCESSING = "upscale_processing"
|
UPSCALE_PROCESSING = "upscale_processing"
|
||||||
UPSCALE_POLLING = "upscale_polling"
|
UPSCALE_POLLING = "upscale_polling"
|
||||||
@@ -107,6 +108,7 @@ class ChatGenerationTaskEventType(str, Enum):
|
|||||||
FINAL_POLL_BEFORE_TIMEOUT_PENDING = "FINAL_POLL_BEFORE_TIMEOUT_PENDING"
|
FINAL_POLL_BEFORE_TIMEOUT_PENDING = "FINAL_POLL_BEFORE_TIMEOUT_PENDING"
|
||||||
GENERATION_RECOVERY_ENQUEUE = "GENERATION_RECOVERY_ENQUEUE"
|
GENERATION_RECOVERY_ENQUEUE = "GENERATION_RECOVERY_ENQUEUE"
|
||||||
GENERATION_RECOVERY_TIMEOUT = "GENERATION_RECOVERY_TIMEOUT"
|
GENERATION_RECOVERY_TIMEOUT = "GENERATION_RECOVERY_TIMEOUT"
|
||||||
|
GENERATION_RECOVERY_INCONSISTENT = "GENERATION_RECOVERY_INCONSISTENT"
|
||||||
|
|
||||||
DOWNLOAD_ENQUEUE = "DOWNLOAD_ENQUEUE"
|
DOWNLOAD_ENQUEUE = "DOWNLOAD_ENQUEUE"
|
||||||
DOWNLOAD_ENQUEUE_FAILED = "DOWNLOAD_ENQUEUE_FAILED"
|
DOWNLOAD_ENQUEUE_FAILED = "DOWNLOAD_ENQUEUE_FAILED"
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.enums.generation_status import (
|
from app.enums.generation_status import GenerationType
|
||||||
GenerationStatus,
|
|
||||||
GenerationType,
|
|
||||||
DURATIONS,
|
|
||||||
ASPECT_RATIOS,
|
|
||||||
RESOLUTIONS,
|
|
||||||
IMAGE_SIZES,
|
|
||||||
)
|
|
||||||
from app.schemas.common import NaiveDatetime, NaiveDatetimeOptional
|
from app.schemas.common import NaiveDatetime, NaiveDatetimeOptional
|
||||||
from app.services.operation_log import log_operation
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizeParams(BaseModel):
|
class OptimizeParams(BaseModel):
|
||||||
project_id: str
|
project_id: str
|
||||||
prompt: str = Field(..., max_length=500)
|
prompt: str = Field(..., max_length=500)
|
||||||
gen_type: GenerationType = Field(GenerationType.video, description="生成类型:video-视频,image-图片")
|
gen_type: GenerationType = Field(GenerationType.video, description="生成类型:video-视频,image-图片")
|
||||||
|
engine_id: str = Field(..., min_length=1, max_length=32, description="提词阶段选定并冻结的生成引擎ID")
|
||||||
|
include_media_references: bool = Field(False, description="资源生成时是否携带本次提词附件;提词完成后不可修改")
|
||||||
duration: int | None = Field(None, description="视频时长(秒),视频生成必填")
|
duration: int | None = Field(None, description="视频时长(秒),视频生成必填")
|
||||||
|
aspect_ratio: str | None = Field(None, description="视频比例,视频生成必填")
|
||||||
|
resolution: str | None = Field(None, description="视频目标分辨率,视频生成必填")
|
||||||
image_size: str | None = Field(None, description="画面分辨率,图片生成使用")
|
image_size: str | None = Field(None, description="画面分辨率,图片生成使用")
|
||||||
image_proportion: str | None = Field(None, description="图片比例,图片生成使用")
|
image_proportion: str | None = Field(None, description="图片比例,图片生成使用")
|
||||||
image_px: str | None = Field(None, description="图片像素大小,图片生成使用")
|
image_px: str | None = Field(None, description="图片像素大小,图片生成使用")
|
||||||
@@ -24,18 +20,10 @@ class OptimizeParams(BaseModel):
|
|||||||
idempotency_key: str | None = Field(None, max_length=64, description="幂等键,防止重复请求")
|
idempotency_key: str | None = Field(None, max_length=64, description="幂等键,防止重复请求")
|
||||||
|
|
||||||
|
|
||||||
class GenerateParams(BaseModel):
|
|
||||||
engine_id: str | None = Field(None, description="生成引擎ID;为空时优先沿用记录引擎,再回退默认引擎")
|
|
||||||
include_media_references: bool = Field(False, description="最终生成时是否携带提词阶段保存的附件")
|
|
||||||
aspect_ratio: str | None = None
|
|
||||||
resolution: str | None = None
|
|
||||||
image_size: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizeResult(BaseModel):
|
class OptimizeResult(BaseModel):
|
||||||
optimized_prompt: str
|
optimized_prompt: str
|
||||||
text_credits_cost: float
|
text_credits_cost: float
|
||||||
# text_tokens_used: int
|
text_tokens_used: int = 0
|
||||||
record: "GenerationRecordOut"
|
record: "GenerationRecordOut"
|
||||||
|
|
||||||
class GenerationRecordOut(BaseModel):
|
class GenerationRecordOut(BaseModel):
|
||||||
@@ -62,11 +50,19 @@ class GenerationRecordOut(BaseModel):
|
|||||||
engine_name: str | None = None
|
engine_name: str | None = None
|
||||||
engine_snapshot: dict | None = None
|
engine_snapshot: dict | None = None
|
||||||
include_media_references: bool = False
|
include_media_references: bool = False
|
||||||
|
config_complete: bool = False
|
||||||
|
config_recoverable: bool = False
|
||||||
|
config_fallback_hint: str | None = None
|
||||||
|
can_generate: bool = False
|
||||||
|
can_retry: bool = False
|
||||||
|
should_poll: bool = False
|
||||||
|
client_status: str = "ready"
|
||||||
|
operation_phase: str = "prompt"
|
||||||
text_credits_cost: float = 0.0
|
text_credits_cost: float = 0.0
|
||||||
# text_tokens_used: int = 0
|
text_tokens_used: int = 0
|
||||||
credits_cost: float = 0.0
|
credits_cost: float = 0.0
|
||||||
# video_tokens_used: int = 0
|
video_tokens_used: int = 0
|
||||||
# image_tokens_used: int = 0
|
image_tokens_used: int = 0
|
||||||
error_message: str | None = None
|
error_message: str | None = None
|
||||||
created_at: NaiveDatetime
|
created_at: NaiveDatetime
|
||||||
generated_at: NaiveDatetimeOptional = None
|
generated_at: NaiveDatetimeOptional = None
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ from app.enums.generation_task import GenerationMode, GenerationOwnerType
|
|||||||
from app.models.base import async_session
|
from app.models.base import async_session
|
||||||
from app.models.chat_generation_task import ChatGenerationTask
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
from app.models.chat_generation_task_event import ChatGenerationTaskEvent
|
from app.models.chat_generation_task_event import ChatGenerationTaskEvent
|
||||||
from app.models.chat_provider_call_log import ChatProviderCallLog
|
|
||||||
from app.models.generation_record import GenerationRecord
|
from app.models.generation_record import GenerationRecord
|
||||||
from app.services.operation_log_service import build_exception_detail, log_operation_event, sanitize_log_value
|
from app.services.operation_log_service import build_exception_detail, log_ai_model_event, log_operation_event, sanitize_log_value
|
||||||
from app.utils.id_gen import generate_id
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
MAX_EXCERPT_CHARS = 2000
|
MAX_EXCERPT_CHARS = 2000
|
||||||
@@ -64,8 +63,14 @@ def _owner_fields(
|
|||||||
resolved_record_id = None
|
resolved_record_id = None
|
||||||
resolved_mode = generation_mode or getattr(obj, "generation_mode", GenerationMode.CHATAPI_ASYNC.value)
|
resolved_mode = generation_mode or getattr(obj, "generation_mode", GenerationMode.CHATAPI_ASYNC.value)
|
||||||
else:
|
else:
|
||||||
resolved_owner_type = owner_type or GenerationOwnerType.CHAT_GENERATION_TASK.value
|
inferred_mode = generation_mode or getattr(obj, "generation_mode", None)
|
||||||
resolved_owner_id = owner_id
|
inferred_owner_type = (
|
||||||
|
GenerationOwnerType.GENERATION_RECORD.value
|
||||||
|
if inferred_mode == GenerationMode.GENERATION_RECORD.value
|
||||||
|
else GenerationOwnerType.CHAT_GENERATION_TASK.value
|
||||||
|
)
|
||||||
|
resolved_owner_type = owner_type or inferred_owner_type
|
||||||
|
resolved_owner_id = owner_id or getattr(obj, "id", None)
|
||||||
if resolved_owner_type == GenerationOwnerType.GENERATION_RECORD.value:
|
if resolved_owner_type == GenerationOwnerType.GENERATION_RECORD.value:
|
||||||
resolved_task_id = None
|
resolved_task_id = None
|
||||||
resolved_record_id = resolved_owner_id
|
resolved_record_id = resolved_owner_id
|
||||||
@@ -201,8 +206,16 @@ async def log_provider_call(
|
|||||||
total_tokens: int = 0,
|
total_tokens: int = 0,
|
||||||
error_code: str | None = None,
|
error_code: str | None = None,
|
||||||
error_message: str | None = None,
|
error_message: str | None = None,
|
||||||
) -> None:
|
call_id: str | None = None,
|
||||||
"""Write an owner-scoped provider call log in a separate transaction."""
|
module: str | None = None,
|
||||||
|
step_code: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""Write provider audit events to the AiModel file log only.
|
||||||
|
|
||||||
|
``ChatProviderCallLog`` is intentionally no longer written. The model and
|
||||||
|
historical table remain registered for backward compatibility, so no schema
|
||||||
|
migration is required.
|
||||||
|
"""
|
||||||
obj = task or record
|
obj = task or record
|
||||||
fields = _owner_fields(
|
fields = _owner_fields(
|
||||||
obj,
|
obj,
|
||||||
@@ -214,34 +227,75 @@ async def log_provider_call(
|
|||||||
generation_mode=generation_mode,
|
generation_mode=generation_mode,
|
||||||
)
|
)
|
||||||
if not fields:
|
if not fields:
|
||||||
return
|
return None
|
||||||
try:
|
|
||||||
async with async_session() as db:
|
resolved_call_id = call_id or generate_id()
|
||||||
db.add(ChatProviderCallLog(
|
resolved_module = module or fields.get("generation_mode") or "generation_pipeline"
|
||||||
id=generate_id(),
|
resolved_step = step_code or api_type
|
||||||
owner_type=fields["owner_type"],
|
common = {
|
||||||
task_id=fields["task_id"],
|
"module": resolved_module,
|
||||||
generation_record_id=fields["generation_record_id"],
|
"step_code": resolved_step,
|
||||||
generation_attempt_no=fields["generation_attempt_no"],
|
"call_id": resolved_call_id,
|
||||||
generation_mode=fields["generation_mode"],
|
"source": "app.services.generation.log_service",
|
||||||
provider=provider,
|
"task_id": fields.get("owner_id"),
|
||||||
api_type=api_type,
|
"owner_type": fields.get("owner_type"),
|
||||||
model=model,
|
"owner_id": fields.get("owner_id"),
|
||||||
engine_id=engine_id,
|
"generation_attempt_no": fields.get("generation_attempt_no"),
|
||||||
status=status,
|
"remote_action": api_type,
|
||||||
|
"remote_request_id": provider_task_id,
|
||||||
|
"model_config_id": engine_id,
|
||||||
|
"model_config_name": engine_id,
|
||||||
|
"model_name": model,
|
||||||
|
"provider": provider,
|
||||||
|
"http_status": http_status,
|
||||||
|
}
|
||||||
|
detail = {
|
||||||
|
"generation_mode": fields.get("generation_mode"),
|
||||||
|
"provider_task_id": provider_task_id,
|
||||||
|
"error_code": error_code,
|
||||||
|
}
|
||||||
|
token_usage = {
|
||||||
|
"prompt_tokens": int(prompt_tokens or 0),
|
||||||
|
"completion_tokens": int(completion_tokens or 0),
|
||||||
|
"total_tokens": int(total_tokens or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
if request_data is not None or str(status).lower() == "request":
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="REQUEST",
|
||||||
|
event_phase="REQUEST",
|
||||||
|
event_status="started",
|
||||||
|
request=request_data if request_data is not None else {},
|
||||||
|
detail=detail,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized_status = str(status or "").lower()
|
||||||
|
if response_data is not None or normalized_status in {"success", "completed", "succeeded"}:
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="RESPONSE",
|
||||||
|
event_phase="RESPONSE",
|
||||||
|
event_status="success" if normalized_status not in {"failed", "error"} else "failed",
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
http_status=http_status,
|
response=response_data,
|
||||||
provider_task_id=provider_task_id,
|
token_usage=token_usage,
|
||||||
request_hash=_hash(request_data),
|
detail=detail,
|
||||||
response_hash=_hash(response_data),
|
error=error_message if normalized_status in {"failed", "error"} else None,
|
||||||
request_excerpt=_excerpt(request_data),
|
**common,
|
||||||
response_excerpt=_excerpt(response_data),
|
)
|
||||||
prompt_tokens=prompt_tokens or 0,
|
|
||||||
completion_tokens=completion_tokens or 0,
|
if normalized_status in {"failed", "error"} or error_message:
|
||||||
total_tokens=total_tokens or 0,
|
log_ai_model_event(
|
||||||
error_code=error_code,
|
event_type="ERROR",
|
||||||
error_message=error_message,
|
event_phase="ERROR",
|
||||||
))
|
event_status="failed",
|
||||||
await db.commit()
|
latency_ms=latency_ms,
|
||||||
except Exception as exc:
|
token_usage=token_usage,
|
||||||
_fallback_log(api_type, fields, exc)
|
detail=build_exception_detail(
|
||||||
|
RuntimeError(error_message or "provider call failed"),
|
||||||
|
detail,
|
||||||
|
),
|
||||||
|
error=error_message or "provider call failed",
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
return resolved_call_id
|
||||||
|
|||||||
@@ -7,24 +7,50 @@ from app.services.generation.pipeline.owner_service import GenerationOwner, owne
|
|||||||
|
|
||||||
|
|
||||||
async def enqueue_generation_create(
|
async def enqueue_generation_create(
|
||||||
owner: GenerationOwner,
|
owner: GenerationOwner | None = None,
|
||||||
*,
|
*,
|
||||||
reason: str,
|
reason: str,
|
||||||
|
owner_type: str | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
generation_attempt_no: int | None = None,
|
||||||
|
generation_mode: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Commit caller-owned state before invoking this function."""
|
"""Commit caller-owned state before invoking this function.
|
||||||
|
|
||||||
|
Scalar owner fields are accepted so callers can avoid touching an ORM object after
|
||||||
|
commit. Existing callers may continue passing ``owner``.
|
||||||
|
"""
|
||||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||||
|
|
||||||
owner_type = owner_type_of(owner)
|
resolved_owner_type = owner_type or (owner_type_of(owner) if owner is not None else None)
|
||||||
attempt_no = int(getattr(owner, "generation_attempt_no", 1) or 1)
|
resolved_owner_id = owner_id or (str(owner.id) if owner is not None else None)
|
||||||
|
resolved_attempt_no = int(
|
||||||
|
generation_attempt_no
|
||||||
|
or (getattr(owner, "generation_attempt_no", 1) if owner is not None else 1)
|
||||||
|
or 1
|
||||||
|
)
|
||||||
|
if not resolved_owner_type or not resolved_owner_id:
|
||||||
|
raise ValueError("投递生成任务缺少 owner_type 或 owner_id")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
chatapi_create_generation_task.apply_async(
|
chatapi_create_generation_task.apply_async(
|
||||||
args=[str(owner.id)],
|
args=[resolved_owner_id],
|
||||||
kwargs={"owner_type": owner_type, "generation_attempt_no": attempt_no},
|
kwargs={
|
||||||
|
"owner_type": resolved_owner_type,
|
||||||
|
"generation_attempt_no": resolved_attempt_no,
|
||||||
|
},
|
||||||
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
||||||
task_id=f"generation-create:{owner_type}:{owner.id}:attempt:{attempt_no}",
|
task_id=(
|
||||||
|
f"generation-create:{resolved_owner_type}:{resolved_owner_id}:"
|
||||||
|
f"attempt:{resolved_attempt_no}"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
owner,
|
owner,
|
||||||
|
owner_type=resolved_owner_type,
|
||||||
|
owner_id=resolved_owner_id,
|
||||||
|
generation_attempt_no=resolved_attempt_no,
|
||||||
|
generation_mode=generation_mode,
|
||||||
event_type=ChatGenerationTaskEventType.GENERATION_RECORD_ENQUEUE_SUCCESS.value,
|
event_type=ChatGenerationTaskEventType.GENERATION_RECORD_ENQUEUE_SUCCESS.value,
|
||||||
message="资源生成创建任务已投递",
|
message="资源生成创建任务已投递",
|
||||||
detail={"reason": reason, "queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
|
detail={"reason": reason, "queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
|
||||||
@@ -32,6 +58,10 @@ async def enqueue_generation_create(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
owner,
|
owner,
|
||||||
|
owner_type=resolved_owner_type,
|
||||||
|
owner_id=resolved_owner_id,
|
||||||
|
generation_attempt_no=resolved_attempt_no,
|
||||||
|
generation_mode=generation_mode,
|
||||||
event_type=ChatGenerationTaskEventType.GENERATION_RECORD_ENQUEUE_FAILED.value,
|
event_type=ChatGenerationTaskEventType.GENERATION_RECORD_ENQUEUE_FAILED.value,
|
||||||
message=str(exc),
|
message=str(exc),
|
||||||
detail={"reason": reason, "queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
|
detail={"reason": reason, "queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.enums.common import LogEventStatusEnum
|
||||||
|
from app.enums.generation_record import (
|
||||||
|
GenerationRecordConfigSourceEnum,
|
||||||
|
GenerationRecordEventTypeEnum,
|
||||||
|
)
|
||||||
|
from app.enums.generation_status import (
|
||||||
|
GenerationType,
|
||||||
|
)
|
||||||
|
from app.models.generation_record import GenerationRecord
|
||||||
|
from app.models.image_engine import ImageEngine
|
||||||
|
from app.models.video_engine import VideoEngine
|
||||||
|
from app.services.generation.ai.engine_service import (
|
||||||
|
IMAGE_DEFAULT_PROPORTION,
|
||||||
|
IMAGE_DEFAULT_PX,
|
||||||
|
IMAGE_DEFAULT_SIZE,
|
||||||
|
VIDEO_DEFAULT_DURATION,
|
||||||
|
VIDEO_DEFAULT_RATIO,
|
||||||
|
VIDEO_DEFAULT_RESOLUTION,
|
||||||
|
image_supported_sizes,
|
||||||
|
normalize_px,
|
||||||
|
parse_json_list,
|
||||||
|
)
|
||||||
|
from app.services.generation.pipeline.generation_record_service import freeze_generation_record_config
|
||||||
|
from app.services.operation_log_service import log_operation_event, log_operation_error
|
||||||
|
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
|
||||||
|
from app.utils.exceptions import InvalidStatusError
|
||||||
|
|
||||||
|
|
||||||
|
_GENERATION_RECORD_LOG_DOMAIN = "generation_record"
|
||||||
|
_GENERATION_RECORD_LOG_MODULE = "generation_record"
|
||||||
|
|
||||||
|
|
||||||
|
def _json_loads_object(value: str | None) -> dict[str, Any] | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(value)
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _json_loads_list(value: str | None) -> list[Any]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(value)
|
||||||
|
except (TypeError, json.JSONDecodeError):
|
||||||
|
return []
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def generation_record_engine_snapshot(record: GenerationRecord) -> dict[str, Any] | None:
|
||||||
|
return _json_loads_object(record.engine_snapshot_json)
|
||||||
|
|
||||||
|
|
||||||
|
def is_generation_record_config_complete(record: GenerationRecord) -> bool:
|
||||||
|
snapshot = generation_record_engine_snapshot(record)
|
||||||
|
if not record.engine_id or not snapshot:
|
||||||
|
return False
|
||||||
|
if record.gen_type == GenerationType.video.value:
|
||||||
|
return bool(record.duration and record.aspect_ratio and record.resolution)
|
||||||
|
if record.gen_type == GenerationType.image.value:
|
||||||
|
return bool(record.image_size and record.image_proportion and record.image_px)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def is_generation_record_config_recoverable(record: GenerationRecord) -> bool:
|
||||||
|
"""Return whether a prompt_optimized legacy row can try server-side config fallback.
|
||||||
|
|
||||||
|
This check intentionally avoids extra DB reads for list pages. The actual engine
|
||||||
|
existence and capability validation is performed while the generate API holds a
|
||||||
|
row lock for the single target record.
|
||||||
|
"""
|
||||||
|
if is_generation_record_config_complete(record):
|
||||||
|
return False
|
||||||
|
if record.status != "prompt_optimized":
|
||||||
|
return False
|
||||||
|
if not record.optimized_prompt:
|
||||||
|
return False
|
||||||
|
return record.gen_type in {GenerationType.video.value, GenerationType.image.value}
|
||||||
|
|
||||||
|
|
||||||
|
def generation_record_config_fallback_hint(record: GenerationRecord) -> str | None:
|
||||||
|
if not is_generation_record_config_recoverable(record):
|
||||||
|
return None
|
||||||
|
return "旧版本记录缺少冻结配置,提交生成时将由后端按可用引擎权重自动补齐一次"
|
||||||
|
|
||||||
|
|
||||||
|
def frozen_generation_record_engine_view(record: GenerationRecord) -> SimpleNamespace:
|
||||||
|
snapshot = generation_record_engine_snapshot(record)
|
||||||
|
if not snapshot:
|
||||||
|
raise InvalidStatusError("该记录缺少冻结的引擎配置,请重新生成提词")
|
||||||
|
snapshot = dict(snapshot)
|
||||||
|
snapshot["id"] = record.engine_id
|
||||||
|
return SimpleNamespace(**snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine_plain_namespace(engine: ImageEngine | VideoEngine | SimpleNamespace) -> SimpleNamespace:
|
||||||
|
if isinstance(engine, SimpleNamespace):
|
||||||
|
return engine
|
||||||
|
return SimpleNamespace(
|
||||||
|
**{
|
||||||
|
key: value
|
||||||
|
for key, value in vars(engine).items()
|
||||||
|
if key != "_sa_instance_state"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_int(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _first_existing_or_default(value: str | None, supported: Iterable[str], default_value: str) -> str:
|
||||||
|
normalized_supported = [str(item).strip() for item in supported if str(item or "").strip()]
|
||||||
|
current = str(value or "").strip()
|
||||||
|
if current and (not normalized_supported or current in normalized_supported):
|
||||||
|
return current
|
||||||
|
if default_value in normalized_supported or not normalized_supported:
|
||||||
|
return default_value
|
||||||
|
return normalized_supported[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _first_duration(value: int | None, supported: Iterable[Any], max_duration: int | None) -> int:
|
||||||
|
supported_ints = [int(item) for item in supported if str(item).isdigit()]
|
||||||
|
current = _safe_int(value)
|
||||||
|
if current and current > 0:
|
||||||
|
if (not supported_ints or current in supported_ints) and (not max_duration or current <= int(max_duration or 0)):
|
||||||
|
return current
|
||||||
|
for item in supported_ints:
|
||||||
|
if item > 0 and (not max_duration or item <= int(max_duration or 0)):
|
||||||
|
return item
|
||||||
|
if max_duration and int(max_duration) > 0:
|
||||||
|
return min(VIDEO_DEFAULT_DURATION, int(max_duration)) or int(max_duration)
|
||||||
|
return VIDEO_DEFAULT_DURATION
|
||||||
|
|
||||||
|
|
||||||
|
def _video_engine_supports_record_params(engine: VideoEngine, record: GenerationRecord) -> bool:
|
||||||
|
ratios = [str(item) for item in parse_json_list(engine.supported_ratios, [])]
|
||||||
|
resolutions = [str(item) for item in parse_json_list(engine.supported_resolutions, [])]
|
||||||
|
durations = [int(item) for item in parse_json_list(engine.supported_durations, []) if str(item).isdigit()]
|
||||||
|
duration = _safe_int(record.duration)
|
||||||
|
if record.aspect_ratio and ratios and record.aspect_ratio not in ratios:
|
||||||
|
return False
|
||||||
|
if record.resolution and resolutions and record.resolution not in resolutions:
|
||||||
|
return False
|
||||||
|
if duration and durations and duration not in durations:
|
||||||
|
return False
|
||||||
|
if duration and int(engine.max_duration or 0) > 0 and duration > int(engine.max_duration or 0):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _image_engine_supports_record_params(engine: ImageEngine, record: GenerationRecord) -> bool:
|
||||||
|
sizes = image_supported_sizes(engine)
|
||||||
|
if record.image_size and sizes and record.image_size not in sizes:
|
||||||
|
return False
|
||||||
|
if record.image_size and record.image_proportion and sizes:
|
||||||
|
ratios = sizes.get(record.image_size) or {}
|
||||||
|
if ratios and record.image_proportion not in ratios:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_active_video_engines(db: AsyncSession) -> list[VideoEngine]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(VideoEngine)
|
||||||
|
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
|
||||||
|
.order_by(VideoEngine.priority.desc(), VideoEngine.created_at.asc(), VideoEngine.id.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_active_image_engines(db: AsyncSession) -> list[ImageEngine]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(ImageEngine)
|
||||||
|
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
|
||||||
|
.order_by(ImageEngine.priority.desc(), ImageEngine.created_at.asc(), ImageEngine.id.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
def _select_video_engine(engines: list[VideoEngine], record: GenerationRecord) -> tuple[VideoEngine, str]:
|
||||||
|
if record.engine_id:
|
||||||
|
for engine in engines:
|
||||||
|
if engine.id == record.engine_id:
|
||||||
|
return engine, "existing_record"
|
||||||
|
for engine in engines:
|
||||||
|
if _video_engine_supports_record_params(engine, record):
|
||||||
|
return engine, "priority_param_match"
|
||||||
|
if engines:
|
||||||
|
return engines[0], "priority_fallback"
|
||||||
|
raise InvalidStatusError("没有可用的视频引擎,无法补齐历史生成配置")
|
||||||
|
|
||||||
|
|
||||||
|
def _select_image_engine(engines: list[ImageEngine], record: GenerationRecord) -> tuple[ImageEngine, str]:
|
||||||
|
if record.engine_id:
|
||||||
|
for engine in engines:
|
||||||
|
if engine.id == record.engine_id:
|
||||||
|
return engine, "existing_record"
|
||||||
|
for engine in engines:
|
||||||
|
if _image_engine_supports_record_params(engine, record):
|
||||||
|
return engine, "priority_param_match"
|
||||||
|
if engines:
|
||||||
|
return engines[0], "priority_fallback"
|
||||||
|
raise InvalidStatusError("没有可用的图片引擎,无法补齐历史生成配置")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_video_record_params(record: GenerationRecord, engine: VideoEngine) -> None:
|
||||||
|
ratios = [str(item) for item in parse_json_list(engine.supported_ratios, [])]
|
||||||
|
resolutions = [str(item) for item in parse_json_list(engine.supported_resolutions, [])]
|
||||||
|
durations = parse_json_list(engine.supported_durations, [])
|
||||||
|
record.duration = _first_duration(record.duration, durations, int(engine.max_duration or 0) or None)
|
||||||
|
record.aspect_ratio = _first_existing_or_default(record.aspect_ratio, ratios, VIDEO_DEFAULT_RATIO)
|
||||||
|
record.resolution = _first_existing_or_default(record.resolution, resolutions, VIDEO_DEFAULT_RESOLUTION)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_image_record_params(record: GenerationRecord, engine: ImageEngine) -> None:
|
||||||
|
sizes = image_supported_sizes(engine)
|
||||||
|
size_keys = [str(item) for item in sizes.keys() if str(item or "").strip()]
|
||||||
|
current_size = str(record.image_size or "").strip()
|
||||||
|
default_size = str(engine.default_size or IMAGE_DEFAULT_SIZE).strip() or IMAGE_DEFAULT_SIZE
|
||||||
|
if current_size and (not sizes or current_size in sizes):
|
||||||
|
image_size = current_size
|
||||||
|
elif default_size in size_keys:
|
||||||
|
image_size = default_size
|
||||||
|
elif IMAGE_DEFAULT_SIZE in size_keys:
|
||||||
|
image_size = IMAGE_DEFAULT_SIZE
|
||||||
|
elif size_keys:
|
||||||
|
image_size = size_keys[0]
|
||||||
|
else:
|
||||||
|
image_size = current_size or default_size or IMAGE_DEFAULT_SIZE
|
||||||
|
|
||||||
|
ratios = sizes.get(image_size) if sizes else {}
|
||||||
|
ratio_keys = [str(item) for item in (ratios or {}).keys() if str(item or "").strip()]
|
||||||
|
current_ratio = str(record.image_proportion or "").strip()
|
||||||
|
if current_ratio and (not ratio_keys or current_ratio in ratio_keys):
|
||||||
|
image_proportion = current_ratio
|
||||||
|
elif IMAGE_DEFAULT_PROPORTION in ratio_keys or not ratio_keys:
|
||||||
|
image_proportion = IMAGE_DEFAULT_PROPORTION
|
||||||
|
else:
|
||||||
|
image_proportion = ratio_keys[0]
|
||||||
|
|
||||||
|
px_map = ratios or {}
|
||||||
|
current_px = normalize_px(str(record.image_px or "").strip()) if record.image_px else ""
|
||||||
|
image_px = normalize_px(str(px_map.get(image_proportion) or "").strip()) or current_px or IMAGE_DEFAULT_PX
|
||||||
|
|
||||||
|
record.image_size = image_size
|
||||||
|
record.image_proportion = image_proportion
|
||||||
|
record.image_px = image_px
|
||||||
|
|
||||||
|
|
||||||
|
def _config_log_detail(
|
||||||
|
record: GenerationRecord,
|
||||||
|
*,
|
||||||
|
source: str,
|
||||||
|
engine_selected_by: str | None = None,
|
||||||
|
before: dict[str, Any] | None = None,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
references = _json_loads_list(record.media_references)
|
||||||
|
detail: dict[str, Any] = {
|
||||||
|
"record_id": record.id,
|
||||||
|
"user_id": record.user_id,
|
||||||
|
"project_id": record.project_id,
|
||||||
|
"gen_type": record.gen_type,
|
||||||
|
"source": source,
|
||||||
|
"engine_selected_by": engine_selected_by,
|
||||||
|
"engine_id": record.engine_id,
|
||||||
|
"duration": record.duration,
|
||||||
|
"aspect_ratio": record.aspect_ratio,
|
||||||
|
"resolution": record.resolution,
|
||||||
|
"provider_generation_resolution": record.provider_generation_resolution,
|
||||||
|
"image_size": record.image_size,
|
||||||
|
"image_proportion": record.image_proportion,
|
||||||
|
"image_px": record.image_px,
|
||||||
|
"include_media_references": bool(record.include_media_references),
|
||||||
|
"reference_count": len(references),
|
||||||
|
"video_upscale_enabled": bool(record.video_upscale_enabled_snapshot),
|
||||||
|
"config_complete": is_generation_record_config_complete(record),
|
||||||
|
}
|
||||||
|
if before:
|
||||||
|
detail["before"] = before
|
||||||
|
if extra:
|
||||||
|
detail.update(extra)
|
||||||
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
def _record_config_before(record: GenerationRecord) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"engine_id": record.engine_id,
|
||||||
|
"has_engine_snapshot": bool(record.engine_snapshot_json),
|
||||||
|
"duration": record.duration,
|
||||||
|
"aspect_ratio": record.aspect_ratio,
|
||||||
|
"resolution": record.resolution,
|
||||||
|
"provider_generation_resolution": record.provider_generation_resolution,
|
||||||
|
"image_size": record.image_size,
|
||||||
|
"image_proportion": record.image_proportion,
|
||||||
|
"image_px": record.image_px,
|
||||||
|
"include_media_references": bool(record.include_media_references),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log_generation_record_config_event(
|
||||||
|
*,
|
||||||
|
event_type: GenerationRecordEventTypeEnum,
|
||||||
|
event_status: LogEventStatusEnum = LogEventStatusEnum.SUCCESS,
|
||||||
|
source: GenerationRecordConfigSourceEnum | str,
|
||||||
|
record: GenerationRecord,
|
||||||
|
message: str | None = None,
|
||||||
|
detail: dict[str, Any] | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
log_operation_event(
|
||||||
|
domain=_GENERATION_RECORD_LOG_DOMAIN,
|
||||||
|
module=_GENERATION_RECORD_LOG_MODULE,
|
||||||
|
event_type=event_type.value,
|
||||||
|
event_status=event_status.value,
|
||||||
|
source=str(source.value if isinstance(source, GenerationRecordConfigSourceEnum) else source),
|
||||||
|
user_id=str(record.user_id) if record.user_id else None,
|
||||||
|
project_id=str(record.project_id) if record.project_id else None,
|
||||||
|
task_id=str(record.id) if record.id else None,
|
||||||
|
message=message,
|
||||||
|
detail=detail,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def freeze_generation_record_config_with_log(
|
||||||
|
record: GenerationRecord,
|
||||||
|
*,
|
||||||
|
engine: ImageEngine | VideoEngine | SimpleNamespace,
|
||||||
|
source: GenerationRecordConfigSourceEnum,
|
||||||
|
) -> None:
|
||||||
|
before = _record_config_before(record)
|
||||||
|
log_generation_record_config_event(
|
||||||
|
event_type=GenerationRecordEventTypeEnum.PROMPT_CONFIG_FREEZE_START,
|
||||||
|
event_status=LogEventStatusEnum.STARTED,
|
||||||
|
source=source,
|
||||||
|
record=record,
|
||||||
|
detail=_config_log_detail(record, source=source.value, before=before),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
freeze_generation_record_config(record, engine=_engine_plain_namespace(engine))
|
||||||
|
except Exception as exc:
|
||||||
|
log_operation_error(
|
||||||
|
domain=_GENERATION_RECORD_LOG_DOMAIN,
|
||||||
|
event_type=GenerationRecordEventTypeEnum.PROMPT_CONFIG_FREEZE_FAILED.value,
|
||||||
|
module=_GENERATION_RECORD_LOG_MODULE,
|
||||||
|
source=source.value,
|
||||||
|
user_id=str(record.user_id) if record.user_id else None,
|
||||||
|
project_id=str(record.project_id) if record.project_id else None,
|
||||||
|
task_id=str(record.id) if record.id else None,
|
||||||
|
detail=_config_log_detail(record, source=source.value, before=before),
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
log_generation_record_config_event(
|
||||||
|
event_type=GenerationRecordEventTypeEnum.PROMPT_CONFIG_FREEZE_SUCCESS,
|
||||||
|
event_status=LogEventStatusEnum.SUCCESS,
|
||||||
|
source=source,
|
||||||
|
record=record,
|
||||||
|
detail=_config_log_detail(
|
||||||
|
record,
|
||||||
|
source=source.value,
|
||||||
|
before=before,
|
||||||
|
extra={"config_changed": before != _record_config_before(record)},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_generation_record_config_frozen(
|
||||||
|
db: AsyncSession,
|
||||||
|
record: GenerationRecord,
|
||||||
|
*,
|
||||||
|
source: GenerationRecordConfigSourceEnum = GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK,
|
||||||
|
) -> bool:
|
||||||
|
"""Ensure one GenerationRecord has a complete frozen config.
|
||||||
|
|
||||||
|
New records should already be complete and are left untouched. Legacy
|
||||||
|
prompt_optimized rows may be missing engine_id, engine_snapshot_json or
|
||||||
|
selected parameters; those are completed server-side without accepting any
|
||||||
|
generate-time user input.
|
||||||
|
|
||||||
|
Returns True when the record was changed.
|
||||||
|
"""
|
||||||
|
if is_generation_record_config_complete(record):
|
||||||
|
log_generation_record_config_event(
|
||||||
|
event_type=GenerationRecordEventTypeEnum.LEGACY_CONFIG_FALLBACK_SKIPPED,
|
||||||
|
event_status=LogEventStatusEnum.SKIPPED,
|
||||||
|
source=GenerationRecordConfigSourceEnum.EXISTING_FROZEN_CONFIG,
|
||||||
|
record=record,
|
||||||
|
detail=_config_log_detail(record, source=GenerationRecordConfigSourceEnum.EXISTING_FROZEN_CONFIG.value),
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if record.gen_type not in {GenerationType.video.value, GenerationType.image.value}:
|
||||||
|
raise InvalidStatusError("不支持的生成类型,无法补齐历史生成配置")
|
||||||
|
|
||||||
|
before = _record_config_before(record)
|
||||||
|
log_generation_record_config_event(
|
||||||
|
event_type=GenerationRecordEventTypeEnum.LEGACY_CONFIG_FALLBACK_START,
|
||||||
|
event_status=LogEventStatusEnum.STARTED,
|
||||||
|
source=source,
|
||||||
|
record=record,
|
||||||
|
detail=_config_log_detail(record, source=source.value, before=before),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine_selected_by = "priority_fallback"
|
||||||
|
if record.gen_type == GenerationType.video.value:
|
||||||
|
engines = await _list_active_video_engines(db)
|
||||||
|
engine, engine_selected_by = _select_video_engine(engines, record)
|
||||||
|
_normalize_video_record_params(record, engine)
|
||||||
|
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
|
||||||
|
db,
|
||||||
|
target_resolution=record.resolution or VIDEO_DEFAULT_RESOLUTION,
|
||||||
|
aspect_ratio=record.aspect_ratio or VIDEO_DEFAULT_RATIO,
|
||||||
|
supported_provider_resolutions=parse_json_list(engine.supported_resolutions, []),
|
||||||
|
)
|
||||||
|
record.provider_generation_resolution = provider_resolution
|
||||||
|
record.video_upscale_enabled_snapshot = upscale_enabled
|
||||||
|
record.video_upscale_snapshot_json = upscale_snapshot_json
|
||||||
|
else:
|
||||||
|
engines = await _list_active_image_engines(db)
|
||||||
|
engine, engine_selected_by = _select_image_engine(engines, record)
|
||||||
|
_normalize_image_record_params(record, engine)
|
||||||
|
record.provider_generation_resolution = None
|
||||||
|
record.video_upscale_enabled_snapshot = False
|
||||||
|
record.video_upscale_snapshot_json = None
|
||||||
|
|
||||||
|
# Historical rows had no explicit resource attachment switch. Missing
|
||||||
|
# values must stay false to avoid silently changing provider input and
|
||||||
|
# billing semantics.
|
||||||
|
record.include_media_references = bool(record.include_media_references)
|
||||||
|
freeze_generation_record_config(record, engine=engine)
|
||||||
|
|
||||||
|
if not is_generation_record_config_complete(record):
|
||||||
|
raise InvalidStatusError("历史生成记录配置自动补齐失败,请重新生成提词")
|
||||||
|
|
||||||
|
log_generation_record_config_event(
|
||||||
|
event_type=GenerationRecordEventTypeEnum.LEGACY_CONFIG_FALLBACK_SUCCESS,
|
||||||
|
event_status=LogEventStatusEnum.SUCCESS,
|
||||||
|
source=source,
|
||||||
|
record=record,
|
||||||
|
detail=_config_log_detail(
|
||||||
|
record,
|
||||||
|
source=source.value,
|
||||||
|
engine_selected_by=engine_selected_by,
|
||||||
|
before=before,
|
||||||
|
extra={"config_changed": before != _record_config_before(record)},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return before != _record_config_before(record)
|
||||||
|
except HTTPException as exc:
|
||||||
|
log_generation_record_config_event(
|
||||||
|
event_type=GenerationRecordEventTypeEnum.LEGACY_CONFIG_FALLBACK_FAILED,
|
||||||
|
event_status=LogEventStatusEnum.FAILED,
|
||||||
|
source=source,
|
||||||
|
record=record,
|
||||||
|
detail=_config_log_detail(record, source=source.value, before=before),
|
||||||
|
error=str(exc.detail),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
log_operation_error(
|
||||||
|
domain=_GENERATION_RECORD_LOG_DOMAIN,
|
||||||
|
event_type=GenerationRecordEventTypeEnum.LEGACY_CONFIG_FALLBACK_FAILED.value,
|
||||||
|
module=_GENERATION_RECORD_LOG_MODULE,
|
||||||
|
source=source.value,
|
||||||
|
user_id=str(record.user_id) if record.user_id else None,
|
||||||
|
project_id=str(record.project_id) if record.project_id else None,
|
||||||
|
task_id=str(record.id) if record.id else None,
|
||||||
|
detail=_config_log_detail(record, source=source.value, before=before),
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
raise
|
||||||
@@ -18,29 +18,48 @@ def _json(data: dict) -> str:
|
|||||||
return json.dumps(data, ensure_ascii=False, default=str)
|
return json.dumps(data, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
def prepare_generation_record_execution(
|
def freeze_generation_record_config(
|
||||||
record: GenerationRecord,
|
record: GenerationRecord,
|
||||||
*,
|
*,
|
||||||
engine: ImageEngine | VideoEngine,
|
engine: ImageEngine | VideoEngine,
|
||||||
attempt_no: int,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
now = datetime.now(timezone.utc)
|
"""Freeze the provider capability and user-selected parameters at prompt time.
|
||||||
reset_execution_fields(record, started_at=now, attempt_no=attempt_no)
|
|
||||||
|
Runtime API keys are intentionally not stored in the snapshot. Provider execution
|
||||||
|
reads only the current secret from the engine row while all capability and selected
|
||||||
|
parameters continue to come from this immutable snapshot.
|
||||||
|
"""
|
||||||
record.engine_id = engine.id
|
record.engine_id = engine.id
|
||||||
if record.gen_type == "image":
|
if record.gen_type == "image":
|
||||||
record.engine_snapshot_json = _json(build_image_snapshot(
|
record.engine_snapshot_json = _json(
|
||||||
|
build_image_snapshot(
|
||||||
engine,
|
engine,
|
||||||
record.image_size or getattr(engine, "default_size", "2K") or "2K",
|
record.image_size or getattr(engine, "default_size", "2K") or "2K",
|
||||||
record.image_proportion or "1:1",
|
record.image_proportion or "1:1",
|
||||||
record.image_px or "2048x2048",
|
record.image_px or "2048x2048",
|
||||||
))
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
record.engine_snapshot_json = _json(build_video_snapshot(
|
record.engine_snapshot_json = _json(
|
||||||
|
build_video_snapshot(
|
||||||
engine,
|
engine,
|
||||||
record.aspect_ratio or "16:9",
|
record.aspect_ratio or "16:9",
|
||||||
record.resolution or "480p",
|
record.resolution or "480p",
|
||||||
int(record.duration or 4),
|
int(record.duration or 4),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_generation_record_execution(
|
||||||
|
record: GenerationRecord,
|
||||||
|
*,
|
||||||
|
attempt_no: int,
|
||||||
|
) -> None:
|
||||||
|
"""Reset execution-only fields without changing the frozen prompt configuration."""
|
||||||
|
if not record.engine_id or not record.engine_snapshot_json:
|
||||||
|
raise ValueError("生成记录缺少冻结的引擎配置")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
reset_execution_fields(record, started_at=now, attempt_no=attempt_no)
|
||||||
record.status = GenerationStatus.generating.value
|
record.status = GenerationStatus.generating.value
|
||||||
record.pipeline_stage = GenerationRecordPipelineStage.QUEUED.value
|
record.pipeline_stage = GenerationRecordPipelineStage.QUEUED.value
|
||||||
|
|
||||||
@@ -51,9 +70,18 @@ async def commit_and_enqueue_generation_record(
|
|||||||
*,
|
*,
|
||||||
reason: str,
|
reason: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
record_id = str(record.id)
|
||||||
|
attempt_no = int(record.generation_attempt_no or 1)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
try:
|
try:
|
||||||
await enqueue_generation_create(record, reason=reason)
|
await enqueue_generation_create(
|
||||||
|
None,
|
||||||
|
reason=reason,
|
||||||
|
owner_type="generation_record",
|
||||||
|
owner_id=record_id,
|
||||||
|
generation_attempt_no=attempt_no,
|
||||||
|
generation_mode="generation_record",
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# queued stage and all execution metadata are already committed; recovery will retry.
|
# Queued stage and execution metadata are committed; recovery will retry.
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class GenerationRecordRecoveryBatch:
|
|||||||
create: list[GenerationOwnerRef]
|
create: list[GenerationOwnerRef]
|
||||||
poll: list[GenerationOwnerRef]
|
poll: list[GenerationOwnerRef]
|
||||||
download: list[GenerationOwnerRef]
|
download: list[GenerationOwnerRef]
|
||||||
|
inconsistent: list[GenerationOwnerRef]
|
||||||
next_cursor: GenerationRecordRecoveryCursor | None
|
next_cursor: GenerationRecordRecoveryCursor | None
|
||||||
|
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ async def find_generation_record_recovery_batch(
|
|||||||
GenerationRecordPipelineStage.DOWNLOAD_QUEUED.value,
|
GenerationRecordPipelineStage.DOWNLOAD_QUEUED.value,
|
||||||
GenerationRecordPipelineStage.DOWNLOADING.value,
|
GenerationRecordPipelineStage.DOWNLOADING.value,
|
||||||
GenerationRecordPipelineStage.RETRY_WAITING.value,
|
GenerationRecordPipelineStage.RETRY_WAITING.value,
|
||||||
|
GenerationRecordPipelineStage.RECOVERY_INCONSISTENT.value,
|
||||||
}
|
}
|
||||||
page_size = max(1, int(limit))
|
page_size = max(1, int(limit))
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
@@ -77,6 +79,18 @@ async def find_generation_record_recovery_batch(
|
|||||||
create: list[GenerationOwnerRef] = []
|
create: list[GenerationOwnerRef] = []
|
||||||
poll: list[GenerationOwnerRef] = []
|
poll: list[GenerationOwnerRef] = []
|
||||||
download: list[GenerationOwnerRef] = []
|
download: list[GenerationOwnerRef] = []
|
||||||
|
inconsistent: list[GenerationOwnerRef] = []
|
||||||
|
create_stages = {
|
||||||
|
GenerationRecordPipelineStage.QUEUED.value,
|
||||||
|
GenerationRecordPipelineStage.PREPARING.value,
|
||||||
|
GenerationRecordPipelineStage.CREATING_PROVIDER_TASK.value,
|
||||||
|
}
|
||||||
|
inconsistent_stages = {
|
||||||
|
GenerationRecordPipelineStage.WAITING_REMOTE.value,
|
||||||
|
GenerationRecordPipelineStage.POLLING.value,
|
||||||
|
GenerationRecordPipelineStage.RESULT_READY.value,
|
||||||
|
GenerationRecordPipelineStage.RECOVERY_INCONSISTENT.value,
|
||||||
|
}
|
||||||
for (
|
for (
|
||||||
owner_id,
|
owner_id,
|
||||||
attempt_no,
|
attempt_no,
|
||||||
@@ -97,7 +111,14 @@ async def find_generation_record_recovery_batch(
|
|||||||
)
|
)
|
||||||
stage = str(pipeline_stage or "")
|
stage = str(pipeline_stage or "")
|
||||||
if str(remote_result_url or "").strip():
|
if str(remote_result_url or "").strip():
|
||||||
if stage == GenerationRecordPipelineStage.RESULT_READY.value:
|
if stage not in {
|
||||||
|
GenerationRecordPipelineStage.DOWNLOAD_QUEUED.value,
|
||||||
|
GenerationRecordPipelineStage.DOWNLOADING.value,
|
||||||
|
GenerationRecordPipelineStage.RETRY_WAITING.value,
|
||||||
|
}:
|
||||||
|
# The remote result URL is stronger recovery evidence than the
|
||||||
|
# persisted stage. Always continue from download instead of
|
||||||
|
# recreating or polling the provider task.
|
||||||
download.append(ref)
|
download.append(ref)
|
||||||
elif stage == GenerationRecordPipelineStage.DOWNLOAD_QUEUED.value:
|
elif stage == GenerationRecordPipelineStage.DOWNLOAD_QUEUED.value:
|
||||||
checked_enqueued_at = ensure_aware_utc(download_enqueued_at)
|
checked_enqueued_at = ensure_aware_utc(download_enqueued_at)
|
||||||
@@ -130,7 +151,9 @@ async def find_generation_record_recovery_batch(
|
|||||||
):
|
):
|
||||||
poll.append(ref)
|
poll.append(ref)
|
||||||
else:
|
else:
|
||||||
if (
|
if stage in inconsistent_stages:
|
||||||
|
inconsistent.append(ref)
|
||||||
|
elif stage in create_stages and (
|
||||||
ensure_aware_utc(provider_create_lease_until) is None
|
ensure_aware_utc(provider_create_lease_until) is None
|
||||||
or ensure_aware_utc(provider_create_lease_until) <= now
|
or ensure_aware_utc(provider_create_lease_until) <= now
|
||||||
):
|
):
|
||||||
@@ -144,5 +167,6 @@ async def find_generation_record_recovery_batch(
|
|||||||
create=create,
|
create=create,
|
||||||
poll=poll,
|
poll=poll,
|
||||||
download=download,
|
download=download,
|
||||||
|
inconsistent=inconsistent,
|
||||||
next_cursor=next_cursor,
|
next_cursor=next_cursor,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -91,9 +90,32 @@ async def _get_model_config(db: AsyncSession) -> ModelConfig:
|
|||||||
|
|
||||||
async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask) -> tuple[str, dict]:
|
async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask) -> tuple[str, dict]:
|
||||||
"""Call ChatAPI once with current request params and attachments. No history context."""
|
"""Call ChatAPI once with current request params and attachments. No history context."""
|
||||||
config = await _get_model_config(db)
|
config_row = await _get_model_config(db)
|
||||||
if config.provider == "mock":
|
if config_row.provider == "mock":
|
||||||
return record.original_prompt, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
original_prompt = str(record.original_prompt or "")
|
||||||
|
await db.commit()
|
||||||
|
return original_prompt, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||||
|
|
||||||
|
user_content = await _build_user_content(record, db)
|
||||||
|
config = SimpleNamespace(
|
||||||
|
id=str(config_row.id),
|
||||||
|
name=str(config_row.name or ""),
|
||||||
|
provider=str(config_row.provider or ""),
|
||||||
|
api_base=str(config_row.api_base or ""),
|
||||||
|
api_key=str(config_row.api_key or ""),
|
||||||
|
model_name=str(config_row.model_name or ""),
|
||||||
|
max_tokens=config_row.max_tokens,
|
||||||
|
temperature=config_row.temperature,
|
||||||
|
)
|
||||||
|
record = SimpleNamespace(
|
||||||
|
id=str(record.id),
|
||||||
|
user_id=str(record.user_id),
|
||||||
|
engine_id=str(record.engine_id or "") or None,
|
||||||
|
generation_mode=str(record.generation_mode or ""),
|
||||||
|
generation_attempt_no=int(record.generation_attempt_no or 1),
|
||||||
|
)
|
||||||
|
# Release all configuration/media lookup reads before the remote request.
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
system_prompt = (
|
system_prompt = (
|
||||||
"你是图片/视频生成提示词整理助手。你的职责是根据用户文字、上传图片/视频和生成参数,"
|
"你是图片/视频生成提示词整理助手。你的职责是根据用户文字、上传图片/视频和生成参数,"
|
||||||
@@ -104,14 +126,26 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
|
|||||||
"model": config.model_name,
|
"model": config.model_name,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": await _build_user_content(record, db)},
|
{"role": "user", "content": user_content},
|
||||||
],
|
],
|
||||||
"max_tokens": config.max_tokens,
|
"max_tokens": config.max_tokens,
|
||||||
"temperature": config.temperature,
|
"temperature": config.temperature,
|
||||||
}
|
}
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
|
call_id = await log_provider_call(
|
||||||
|
record,
|
||||||
|
provider=config.provider,
|
||||||
|
api_type="chat_prompt",
|
||||||
|
model=config.model_name,
|
||||||
|
engine_id=record.engine_id,
|
||||||
|
status="request",
|
||||||
|
request_data=request_data,
|
||||||
|
module="generation_record",
|
||||||
|
step_code="prompt_optimize",
|
||||||
|
)
|
||||||
async with provider_limit("ark_chat_prompt", settings.ARK_CHAT_PROMPT_MAX_CONCURRENCY):
|
async with provider_limit("ark_chat_prompt", settings.ARK_CHAT_PROMPT_MAX_CONCURRENCY):
|
||||||
async with httpx.AsyncClient(timeout=settings.CHATAPI_REQUEST_TIMEOUT_SECONDS) as client:
|
async with httpx.AsyncClient(timeout=settings.CHATAPI_REQUEST_TIMEOUT_SECONDS) as client:
|
||||||
|
response: httpx.Response | None = None
|
||||||
try:
|
try:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{config.api_base.rstrip('/')}/chat/completions",
|
f"{config.api_base.rstrip('/')}/chat/completions",
|
||||||
@@ -123,6 +157,7 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
|
|||||||
)
|
)
|
||||||
latency_ms = int((time.perf_counter() - started) * 1000)
|
latency_ms = int((time.perf_counter() - started) * 1000)
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
|
message = response.text[:1000]
|
||||||
await log_provider_call(
|
await log_provider_call(
|
||||||
record,
|
record,
|
||||||
provider=config.provider,
|
provider=config.provider,
|
||||||
@@ -132,13 +167,17 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
|
|||||||
status="failed",
|
status="failed",
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
http_status=response.status_code,
|
http_status=response.status_code,
|
||||||
request_data=request_data,
|
|
||||||
response_data=response.text,
|
response_data=response.text,
|
||||||
error_message=response.text[:1000],
|
error_message=message,
|
||||||
|
call_id=call_id,
|
||||||
|
module="generation_record",
|
||||||
|
step_code="prompt_optimize",
|
||||||
)
|
)
|
||||||
raise RuntimeError(f"ChatAPI HTTP {response.status_code}: {response.text}")
|
raise RuntimeError(f"ChatAPI HTTP {response.status_code}: {message}")
|
||||||
data = response.json()
|
data = response.json()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
if isinstance(exc, RuntimeError) and str(exc).startswith("ChatAPI HTTP "):
|
||||||
|
raise
|
||||||
latency_ms = int((time.perf_counter() - started) * 1000)
|
latency_ms = int((time.perf_counter() - started) * 1000)
|
||||||
await log_provider_call(
|
await log_provider_call(
|
||||||
record,
|
record,
|
||||||
@@ -148,9 +187,12 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
|
|||||||
engine_id=record.engine_id,
|
engine_id=record.engine_id,
|
||||||
status="failed",
|
status="failed",
|
||||||
latency_ms=latency_ms,
|
latency_ms=latency_ms,
|
||||||
request_data=request_data,
|
http_status=response.status_code if response is not None else None,
|
||||||
response_data=None,
|
response_data=response.text if response is not None else None,
|
||||||
error_message=str(exc),
|
error_message=str(exc),
|
||||||
|
call_id=call_id,
|
||||||
|
module="generation_record",
|
||||||
|
step_code="prompt_optimize",
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -158,11 +200,42 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
|
|||||||
input_tokens = int(usage.get("prompt_tokens", 0) or 0)
|
input_tokens = int(usage.get("prompt_tokens", 0) or 0)
|
||||||
output_tokens = int(usage.get("completion_tokens", 0) or 0)
|
output_tokens = int(usage.get("completion_tokens", 0) or 0)
|
||||||
total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0)
|
total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0)
|
||||||
|
await log_provider_call(
|
||||||
|
record,
|
||||||
|
provider=config.provider,
|
||||||
|
api_type="chat_prompt",
|
||||||
|
model=config.model_name,
|
||||||
|
engine_id=record.engine_id,
|
||||||
|
status="success",
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
http_status=response.status_code if response is not None else 200,
|
||||||
|
response_data=data,
|
||||||
|
prompt_tokens=input_tokens,
|
||||||
|
completion_tokens=output_tokens,
|
||||||
|
total_tokens=total_tokens,
|
||||||
|
call_id=call_id,
|
||||||
|
module="generation_record",
|
||||||
|
step_code="prompt_optimize",
|
||||||
|
)
|
||||||
|
|
||||||
content = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
content = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
||||||
if not content:
|
if not content:
|
||||||
|
await log_provider_call(
|
||||||
|
record,
|
||||||
|
provider=config.provider,
|
||||||
|
api_type="chat_prompt",
|
||||||
|
model=config.model_name,
|
||||||
|
engine_id=record.engine_id,
|
||||||
|
status="failed",
|
||||||
|
error_message="ChatAPI未返回有效prompt",
|
||||||
|
call_id=call_id,
|
||||||
|
module="generation_record",
|
||||||
|
step_code="prompt_optimize",
|
||||||
|
)
|
||||||
raise RuntimeError("ChatAPI未返回有效prompt")
|
raise RuntimeError("ChatAPI未返回有效prompt")
|
||||||
|
|
||||||
token_usage_id = generate_id()
|
token_usage_id = generate_id()
|
||||||
|
try:
|
||||||
db.add(TokenUsage(
|
db.add(TokenUsage(
|
||||||
id=token_usage_id,
|
id=token_usage_id,
|
||||||
model_config_id=config.id,
|
model_config_id=config.id,
|
||||||
@@ -174,22 +247,20 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
|
|||||||
total_tokens=total_tokens,
|
total_tokens=total_tokens,
|
||||||
))
|
))
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
except Exception as exc:
|
||||||
await log_provider_call(
|
await log_provider_call(
|
||||||
record,
|
record,
|
||||||
provider=config.provider,
|
provider=config.provider,
|
||||||
api_type="chat_prompt",
|
api_type="chat_prompt",
|
||||||
model=config.model_name,
|
model=config.model_name,
|
||||||
engine_id=record.engine_id,
|
engine_id=record.engine_id,
|
||||||
status="success",
|
status="failed",
|
||||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
error_message=f"token usage写入失败: {exc}",
|
||||||
http_status=200,
|
call_id=call_id,
|
||||||
request_data=request_data,
|
module="generation_record",
|
||||||
response_data=data,
|
step_code="prompt_optimize",
|
||||||
prompt_tokens=input_tokens,
|
|
||||||
completion_tokens=output_tokens,
|
|
||||||
total_tokens=total_tokens,
|
|
||||||
)
|
)
|
||||||
|
raise
|
||||||
return content, {
|
return content, {
|
||||||
"token_usage_id": token_usage_id,
|
"token_usage_id": token_usage_id,
|
||||||
"model_config_id": config.id,
|
"model_config_id": config.id,
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ from app.services.generation.pipeline.owner_service import (
|
|||||||
from app.models.image_engine import ImageEngine
|
from app.models.image_engine import ImageEngine
|
||||||
from app.models.video_engine import VideoEngine
|
from app.models.video_engine import VideoEngine
|
||||||
from app.services.generation.log_service import log_provider_call
|
from app.services.generation.log_service import log_provider_call
|
||||||
from app.services.image_gen import ImageProviderError, poll_image_task_status, submit_image_task
|
from app.services.image_gen import poll_image_task_status, submit_image_task
|
||||||
from app.services.provider_limit import provider_limit
|
from app.services.provider_limit import provider_limit
|
||||||
from app.services.video_gen import poll_task_status, submit_video_task
|
from app.services.video_gen import poll_task_status, submit_video_task
|
||||||
from app.types.generation.provider import ImageProviderBatchResult
|
from app.types.generation.provider import ImageProviderBatchResult
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
|
||||||
def _loads(data: str | None) -> dict:
|
def _loads(data: str | None) -> dict:
|
||||||
@@ -43,6 +44,17 @@ def _try_json(value: Any) -> Any:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_owner(task: GenerationOwner) -> SimpleNamespace:
|
||||||
|
"""Copy loaded scalar fields before commit closes the current transaction."""
|
||||||
|
values = {
|
||||||
|
key: value
|
||||||
|
for key, value in vars(task).items()
|
||||||
|
if key != "_sa_instance_state"
|
||||||
|
}
|
||||||
|
values.setdefault("generation_mode", getattr(task, "generation_mode", None) or "generation_record")
|
||||||
|
return SimpleNamespace(**values)
|
||||||
|
|
||||||
|
|
||||||
async def get_runtime_engine(db: AsyncSession, task: GenerationOwner) -> Any:
|
async def get_runtime_engine(db: AsyncSession, task: GenerationOwner) -> Any:
|
||||||
"""使用任务快照冻结历史参数,只从当前引擎记录读取密钥。"""
|
"""使用任务快照冻结历史参数,只从当前引擎记录读取密钥。"""
|
||||||
snapshot = _loads(task.engine_snapshot_json)
|
snapshot = _loads(task.engine_snapshot_json)
|
||||||
@@ -103,37 +115,18 @@ async def create_provider_task(db: AsyncSession, task: GenerationOwner) -> dict:
|
|||||||
|
|
||||||
async def _create_video_task(db: AsyncSession, task: GenerationOwner) -> dict:
|
async def _create_video_task(db: AsyncSession, task: GenerationOwner) -> dict:
|
||||||
engine = await get_runtime_engine(db, task)
|
engine = await get_runtime_engine(db, task)
|
||||||
|
task_snapshot = _snapshot_owner(task)
|
||||||
|
include_references = owner_include_media_references(task_snapshot)
|
||||||
# Close the engine lookup transaction before the long provider HTTP call.
|
# Close the engine lookup transaction before the long provider HTTP call.
|
||||||
await db.commit()
|
await db.commit()
|
||||||
started = time.perf_counter()
|
|
||||||
async with provider_limit("ark_video_create", settings.ARK_VIDEO_CREATE_MAX_CONCURRENCY):
|
async with provider_limit("ark_video_create", settings.ARK_VIDEO_CREATE_MAX_CONCURRENCY):
|
||||||
try:
|
provider_task_id = await submit_video_task(
|
||||||
provider_task_id = await submit_video_task(None, engine, task, include_media_references=owner_include_media_references(task))
|
None,
|
||||||
response = {"task_id": provider_task_id}
|
engine,
|
||||||
await log_provider_call(
|
task_snapshot,
|
||||||
task,
|
include_media_references=include_references,
|
||||||
provider=engine.provider,
|
|
||||||
api_type="video_create",
|
|
||||||
model=engine.model_name,
|
|
||||||
engine_id=task.engine_id,
|
|
||||||
status="success",
|
|
||||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
|
||||||
provider_task_id=provider_task_id,
|
|
||||||
response_data=response,
|
|
||||||
)
|
)
|
||||||
return {"task_id": provider_task_id, "response_data": response}
|
return {"task_id": provider_task_id, "response_data": {"task_id": provider_task_id}}
|
||||||
except Exception as exc:
|
|
||||||
await log_provider_call(
|
|
||||||
task,
|
|
||||||
provider=engine.provider,
|
|
||||||
api_type="video_create",
|
|
||||||
model=engine.model_name,
|
|
||||||
engine_id=task.engine_id,
|
|
||||||
status="failed",
|
|
||||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
|
||||||
error_message=str(exc),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def create_image_sync_batch_result(
|
async def create_image_sync_batch_result(
|
||||||
@@ -143,10 +136,11 @@ async def create_image_sync_batch_result(
|
|||||||
generation_count: int,
|
generation_count: int,
|
||||||
) -> ImageProviderBatchResult:
|
) -> ImageProviderBatchResult:
|
||||||
engine = await get_runtime_engine(db, task)
|
engine = await get_runtime_engine(db, task)
|
||||||
|
task_snapshot = _snapshot_owner(task)
|
||||||
# Do not keep a database transaction open while the synchronous provider call runs.
|
# Do not keep a database transaction open while the synchronous provider call runs.
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return await create_image_sync_batch_result_with_engine(
|
return await create_image_sync_batch_result_with_engine(
|
||||||
task,
|
task_snapshot,
|
||||||
engine,
|
engine,
|
||||||
generation_count=generation_count,
|
generation_count=generation_count,
|
||||||
)
|
)
|
||||||
@@ -163,11 +157,8 @@ async def create_image_sync_batch_result_with_engine(
|
|||||||
generation_count > 1 时是一次组图 API 调用;失败后绝不退化为多次单图调用。
|
generation_count > 1 时是一次组图 API 调用;失败后绝不退化为多次单图调用。
|
||||||
"""
|
"""
|
||||||
count = max(1, int(generation_count or 1))
|
count = max(1, int(generation_count or 1))
|
||||||
started = time.perf_counter()
|
|
||||||
api_type = "image_sync_batch_create" if count > 1 else "image_sync_create"
|
|
||||||
async with provider_limit("ark_image_sync_create", settings.ARK_IMAGE_CREATE_MAX_CONCURRENCY):
|
async with provider_limit("ark_image_sync_create", settings.ARK_IMAGE_CREATE_MAX_CONCURRENCY):
|
||||||
try:
|
return await asyncio.to_thread(
|
||||||
result = await asyncio.to_thread(
|
|
||||||
submit_image_task,
|
submit_image_task,
|
||||||
None,
|
None,
|
||||||
engine,
|
engine,
|
||||||
@@ -175,34 +166,6 @@ async def create_image_sync_batch_result_with_engine(
|
|||||||
include_media_references=owner_include_media_references(task),
|
include_media_references=owner_include_media_references(task),
|
||||||
generation_count=count,
|
generation_count=count,
|
||||||
)
|
)
|
||||||
response_data = result.get("response_data") or result
|
|
||||||
await log_provider_call(
|
|
||||||
task,
|
|
||||||
provider=engine.provider,
|
|
||||||
api_type=api_type,
|
|
||||||
model=engine.model_name,
|
|
||||||
engine_id=task.engine_id,
|
|
||||||
status="success",
|
|
||||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
|
||||||
provider_task_id=None,
|
|
||||||
response_data=response_data,
|
|
||||||
total_tokens=int(result.get("image_tokens", 0) or 0),
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as exc:
|
|
||||||
error_message = exc.safe_message if isinstance(exc, ImageProviderError) else str(exc)
|
|
||||||
await log_provider_call(
|
|
||||||
task,
|
|
||||||
provider=engine.provider,
|
|
||||||
api_type=api_type,
|
|
||||||
model=engine.model_name,
|
|
||||||
engine_id=task.engine_id,
|
|
||||||
status="failed",
|
|
||||||
latency_ms=int((time.perf_counter() - started) * 1000),
|
|
||||||
error_message=error_message,
|
|
||||||
response_data=exc.as_dict() if isinstance(exc, ImageProviderError) else None,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
async def create_image_sync_result(db: AsyncSession, task: GenerationOwner) -> dict:
|
async def create_image_sync_result(db: AsyncSession, task: GenerationOwner) -> dict:
|
||||||
@@ -226,13 +189,59 @@ async def create_image_sync_result(db: AsyncSession, task: GenerationOwner) -> d
|
|||||||
|
|
||||||
async def poll_provider_task(db: AsyncSession, task: GenerationOwner) -> dict:
|
async def poll_provider_task(db: AsyncSession, task: GenerationOwner) -> dict:
|
||||||
engine = await get_runtime_engine(db, task)
|
engine = await get_runtime_engine(db, task)
|
||||||
|
task_snapshot = _snapshot_owner(task)
|
||||||
|
task_id = owner_provider_task_id(task_snapshot)
|
||||||
# Polling may block on the remote provider; release the lookup transaction first.
|
# Polling may block on the remote provider; release the lookup transaction first.
|
||||||
await db.commit()
|
await db.commit()
|
||||||
task_id = owner_provider_task_id(task)
|
|
||||||
if not task_id:
|
if not task_id:
|
||||||
raise ValueError("缺少供应商任务ID")
|
raise ValueError("缺少供应商任务ID")
|
||||||
if task.gen_type == "video":
|
|
||||||
|
api_type = f"{task_snapshot.gen_type}_poll"
|
||||||
|
call_id = generate_id()
|
||||||
|
await log_provider_call(
|
||||||
|
task_snapshot,
|
||||||
|
provider=engine.provider,
|
||||||
|
api_type=api_type,
|
||||||
|
model=engine.model_name,
|
||||||
|
engine_id=task_snapshot.engine_id,
|
||||||
|
status="request",
|
||||||
|
provider_task_id=task_id,
|
||||||
|
request_data={"provider_task_id": task_id},
|
||||||
|
call_id=call_id,
|
||||||
|
)
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
if task_snapshot.gen_type == "video":
|
||||||
async with provider_limit("ark_video_poll", settings.ARK_VIDEO_POLL_MAX_CONCURRENCY):
|
async with provider_limit("ark_video_poll", settings.ARK_VIDEO_POLL_MAX_CONCURRENCY):
|
||||||
return await poll_task_status(engine, task_id)
|
result = await poll_task_status(engine, task_id)
|
||||||
|
else:
|
||||||
async with provider_limit("ark_image_poll", settings.ARK_IMAGE_POLL_MAX_CONCURRENCY):
|
async with provider_limit("ark_image_poll", settings.ARK_IMAGE_POLL_MAX_CONCURRENCY):
|
||||||
return await poll_image_task_status(engine, task_id)
|
result = await poll_image_task_status(engine, task_id)
|
||||||
|
await log_provider_call(
|
||||||
|
task_snapshot,
|
||||||
|
provider=engine.provider,
|
||||||
|
api_type=api_type,
|
||||||
|
model=engine.model_name,
|
||||||
|
engine_id=task_snapshot.engine_id,
|
||||||
|
status="success",
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
provider_task_id=task_id,
|
||||||
|
response_data=_try_json(result.get("response_data")) or result,
|
||||||
|
total_tokens=int(result.get("video_tokens", 0) or result.get("image_tokens", 0) or 0),
|
||||||
|
call_id=call_id,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as exc:
|
||||||
|
await log_provider_call(
|
||||||
|
task_snapshot,
|
||||||
|
provider=engine.provider,
|
||||||
|
api_type=api_type,
|
||||||
|
model=engine.model_name,
|
||||||
|
engine_id=task_snapshot.engine_id,
|
||||||
|
status="failed",
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
provider_task_id=task_id,
|
||||||
|
error_message=str(exc),
|
||||||
|
call_id=call_id,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|||||||
@@ -95,6 +95,45 @@ async def _load_chat_task_for_update(
|
|||||||
return owner if isinstance(owner, ChatGenerationTask) else None
|
return owner if isinstance(owner, ChatGenerationTask) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_task_post_commit_snapshot(task: ChatGenerationTask) -> Any:
|
||||||
|
"""Capture fields used by Redis/Celery/logging before committing the ORM row."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=str(task.id),
|
||||||
|
generation_attempt_no=int(task.generation_attempt_no or 1),
|
||||||
|
generation_mode=str(task.generation_mode or GenerationMode.CHATAPI_ASYNC.value),
|
||||||
|
provider_task_id=str(task.provider_task_id or "") or None,
|
||||||
|
seedance_task_id=str(task.seedance_task_id or "") or None,
|
||||||
|
gen_type=str(task.gen_type or ""),
|
||||||
|
pipeline_stage=str(task.pipeline_stage or ""),
|
||||||
|
poll_count=int(task.poll_count or 0),
|
||||||
|
poll_error_count=int(task.poll_error_count or 0),
|
||||||
|
manual_retry_count=int(task.manual_retry_count or 0),
|
||||||
|
poll_started_at=task.poll_started_at,
|
||||||
|
poll_interval_seconds=int(task.poll_interval_seconds or 0),
|
||||||
|
last_poll_at=task.last_poll_at,
|
||||||
|
next_poll_at=task.next_poll_at,
|
||||||
|
poll_lease_until=task.poll_lease_until,
|
||||||
|
deadline_at=task.deadline_at,
|
||||||
|
user_id=str(task.user_id or "") or None,
|
||||||
|
project_id=str(task.project_id or "") or None,
|
||||||
|
error_message=str(task.error_message or "") or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _reload_chat_task_after_commit(
|
||||||
|
db: AsyncSession, task_id: str
|
||||||
|
) -> ChatGenerationTask | None:
|
||||||
|
owner = await load_generation_owner(
|
||||||
|
db,
|
||||||
|
owner_type=GenerationOwnerType.CHAT_GENERATION_TASK.value,
|
||||||
|
owner_id=str(task_id),
|
||||||
|
for_update=False,
|
||||||
|
)
|
||||||
|
return owner if isinstance(owner, ChatGenerationTask) else None
|
||||||
|
|
||||||
|
|
||||||
def _now() -> datetime:
|
def _now() -> datetime:
|
||||||
return datetime.now(timezone.utc)
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
@@ -125,6 +164,7 @@ def _is_final_task_state(task: ChatGenerationTask) -> bool:
|
|||||||
ChatGenerationPipelineStage.FAILED.value,
|
ChatGenerationPipelineStage.FAILED.value,
|
||||||
ChatGenerationPipelineStage.TIMEOUT.value,
|
ChatGenerationPipelineStage.TIMEOUT.value,
|
||||||
ChatGenerationPipelineStage.DOWNLOAD_FAILED.value,
|
ChatGenerationPipelineStage.DOWNLOAD_FAILED.value,
|
||||||
|
ChatGenerationPipelineStage.UPSCALE_FAILED.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -430,11 +470,17 @@ async def _mark_timeout(
|
|||||||
error_message=error_message,
|
error_message=error_message,
|
||||||
pipeline_stage=ChatGenerationPipelineStage.TIMEOUT.value,
|
pipeline_stage=ChatGenerationPipelineStage.TIMEOUT.value,
|
||||||
)
|
)
|
||||||
|
snapshot = _chat_task_post_commit_snapshot(task)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await notify_owner_finished(db, task)
|
fresh_task = await _reload_chat_task_after_commit(db, snapshot.id)
|
||||||
await _remove_poll_active(_chat_registry_id(task))
|
if fresh_task is not None:
|
||||||
|
await notify_owner_finished(db, fresh_task)
|
||||||
|
await _remove_poll_active(_chat_registry_id(snapshot))
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
task,
|
owner_type=GenerationOwnerType.CHAT_GENERATION_TASK.value,
|
||||||
|
owner_id=snapshot.id,
|
||||||
|
generation_attempt_no=snapshot.generation_attempt_no,
|
||||||
|
generation_mode=snapshot.generation_mode,
|
||||||
event_type=ChatGenerationTaskEventType.TASK_TIMEOUT.value,
|
event_type=ChatGenerationTaskEventType.TASK_TIMEOUT.value,
|
||||||
to_status="failed",
|
to_status="failed",
|
||||||
to_stage=ChatGenerationPipelineStage.TIMEOUT.value,
|
to_stage=ChatGenerationPipelineStage.TIMEOUT.value,
|
||||||
@@ -456,10 +502,21 @@ async def _mark_failed(
|
|||||||
error_message=error_message,
|
error_message=error_message,
|
||||||
pipeline_stage=ChatGenerationPipelineStage.FAILED.value,
|
pipeline_stage=ChatGenerationPipelineStage.FAILED.value,
|
||||||
)
|
)
|
||||||
|
snapshot = _chat_task_post_commit_snapshot(task)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await notify_owner_finished(db, task)
|
fresh_task = await _reload_chat_task_after_commit(db, snapshot.id)
|
||||||
await _remove_poll_active(_chat_registry_id(task))
|
if fresh_task is not None:
|
||||||
await log_task_event(task, event_type=event_type, message=task.error_message, detail=detail)
|
await notify_owner_finished(db, fresh_task)
|
||||||
|
await _remove_poll_active(_chat_registry_id(snapshot))
|
||||||
|
await log_task_event(
|
||||||
|
owner_type=GenerationOwnerType.CHAT_GENERATION_TASK.value,
|
||||||
|
owner_id=snapshot.id,
|
||||||
|
generation_attempt_no=snapshot.generation_attempt_no,
|
||||||
|
generation_mode=snapshot.generation_mode,
|
||||||
|
event_type=event_type,
|
||||||
|
message=snapshot.error_message or error_message,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
return "mark_failed"
|
return "mark_failed"
|
||||||
|
|
||||||
|
|
||||||
@@ -475,8 +532,8 @@ async def recover_one_generation_task(
|
|||||||
分流原则:
|
分流原则:
|
||||||
1. 已有 remote_result_url:只恢复下载,不 poll,不重新 create。
|
1. 已有 remote_result_url:只恢复下载,不 poll,不重新 create。
|
||||||
2. 已有 provider_task_id/seedance_task_id:恢复 poll。
|
2. 已有 provider_task_id/seedance_task_id:恢复 poll。
|
||||||
3. 无结果 URL、无供应商任务 ID:deadline 未过才恢复 create。
|
3. 仅 queued/preparing/creating_provider_task 且无远程证据时允许恢复 create。
|
||||||
4. 无结果 URL、无供应商任务 ID:deadline 已过直接超时失败,不再补救生成。
|
4. waiting_remote/polling/result_ready 缺少对应证据时隔离,deadline 到期后失败退款。
|
||||||
"""
|
"""
|
||||||
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
from app.tasks.generation_create_tasks import chatapi_create_generation_task
|
||||||
from app.tasks.generation_download_tasks import enqueue_download_task
|
from app.tasks.generation_download_tasks import enqueue_download_task
|
||||||
@@ -543,21 +600,25 @@ async def recover_one_generation_task(
|
|||||||
if is_deadline_expired:
|
if is_deadline_expired:
|
||||||
if has_provider_task_id:
|
if has_provider_task_id:
|
||||||
task.pipeline_stage = ChatGenerationPipelineStage.WAITING_REMOTE.value
|
task.pipeline_stage = ChatGenerationPipelineStage.WAITING_REMOTE.value
|
||||||
|
snapshot = _chat_task_post_commit_snapshot(task)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
task,
|
owner_type=GenerationOwnerType.CHAT_GENERATION_TASK.value,
|
||||||
|
owner_id=snapshot.id,
|
||||||
|
generation_attempt_no=snapshot.generation_attempt_no,
|
||||||
|
generation_mode=snapshot.generation_mode,
|
||||||
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value,
|
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value,
|
||||||
message=f"{source} 发现任务已到 deadline 且存在供应商任务ID,投递 poll 队列做最终查询",
|
message=f"{source} 发现任务已到 deadline 且存在供应商任务ID,投递 poll 队列做最终查询",
|
||||||
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
|
detail={"pipeline_stage": snapshot.pipeline_stage, "payload": redis_payload},
|
||||||
)
|
)
|
||||||
poll_generation_task.apply_async(
|
poll_generation_task.apply_async(
|
||||||
args=[task.id],
|
args=[snapshot.id],
|
||||||
kwargs={"force_due": True, "owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": int(task.generation_attempt_no or 1)},
|
kwargs={"force_due": True, "owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": snapshot.generation_attempt_no},
|
||||||
queue=POLL_QUEUE,
|
queue=POLL_QUEUE,
|
||||||
countdown=0,
|
countdown=0,
|
||||||
)
|
)
|
||||||
await register_poll_active(
|
await register_poll_active(
|
||||||
task,
|
snapshot,
|
||||||
check_at=_poll_queue_timeout_at(),
|
check_at=_poll_queue_timeout_at(),
|
||||||
reason=f"{source}_deadline_final_poll",
|
reason=f"{source}_deadline_final_poll",
|
||||||
)
|
)
|
||||||
@@ -575,21 +636,25 @@ async def recover_one_generation_task(
|
|||||||
if is_video_generation_task(task):
|
if is_video_generation_task(task):
|
||||||
ensure_video_poll_fields(task, now=current_time)
|
ensure_video_poll_fields(task, now=current_time)
|
||||||
if is_poll_not_due(task, now=current_time):
|
if is_poll_not_due(task, now=current_time):
|
||||||
|
snapshot = _chat_task_post_commit_snapshot(task)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await register_poll_active(
|
await register_poll_active(
|
||||||
task,
|
snapshot,
|
||||||
check_at=task.next_poll_at,
|
check_at=snapshot.next_poll_at,
|
||||||
next_poll_at=task.next_poll_at,
|
next_poll_at=snapshot.next_poll_at,
|
||||||
reason=f"{source}_video_poll_not_due",
|
reason=f"{source}_video_poll_not_due",
|
||||||
)
|
)
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
task,
|
owner_type=GenerationOwnerType.CHAT_GENERATION_TASK.value,
|
||||||
|
owner_id=snapshot.id,
|
||||||
|
generation_attempt_no=snapshot.generation_attempt_no,
|
||||||
|
generation_mode=snapshot.generation_mode,
|
||||||
event_type=ChatGenerationTaskEventType.POLL_SKIP_NOT_DUE.value,
|
event_type=ChatGenerationTaskEventType.POLL_SKIP_NOT_DUE.value,
|
||||||
message=f"{source} 发现视频任务尚未到下一次轮询时间,启动容灾不提前投递 poll",
|
message=f"{source} 发现视频任务尚未到下一次轮询时间,启动容灾不提前投递 poll",
|
||||||
detail={
|
detail={
|
||||||
"pipeline_stage": task.pipeline_stage,
|
"pipeline_stage": snapshot.pipeline_stage,
|
||||||
"payload": redis_payload,
|
"payload": redis_payload,
|
||||||
"next_poll_at": task.next_poll_at,
|
"next_poll_at": snapshot.next_poll_at,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return "skip_video_poll_not_due"
|
return "skip_video_poll_not_due"
|
||||||
@@ -600,28 +665,32 @@ async def recover_one_generation_task(
|
|||||||
# 这里仍复用 next_poll_at 做短暂队列保护,避免启动容灾重复投递。
|
# 这里仍复用 next_poll_at 做短暂队列保护,避免启动容灾重复投递。
|
||||||
# 真正消费时通过 force_due=True 跳过“未到期”校验,避免保护时间反向阻塞本次 poll。
|
# 真正消费时通过 force_due=True 跳过“未到期”校验,避免保护时间反向阻塞本次 poll。
|
||||||
task.next_poll_at = queue_hold_until
|
task.next_poll_at = queue_hold_until
|
||||||
|
snapshot = _chat_task_post_commit_snapshot(task)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
task,
|
owner_type=GenerationOwnerType.CHAT_GENERATION_TASK.value,
|
||||||
|
owner_id=snapshot.id,
|
||||||
|
generation_attempt_no=snapshot.generation_attempt_no,
|
||||||
|
generation_mode=snapshot.generation_mode,
|
||||||
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value,
|
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value,
|
||||||
message=f"{source} 发现任务存在供应商任务ID,恢复投递轮询队列",
|
message=f"{source} 发现任务存在供应商任务ID,恢复投递轮询队列",
|
||||||
detail={
|
detail={
|
||||||
"pipeline_stage": task.pipeline_stage,
|
"pipeline_stage": snapshot.pipeline_stage,
|
||||||
"payload": redis_payload,
|
"payload": redis_payload,
|
||||||
"due_next_poll_at": original_next_poll_at,
|
"due_next_poll_at": original_next_poll_at,
|
||||||
"queue_hold_until": queue_hold_until,
|
"queue_hold_until": queue_hold_until,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
poll_generation_task.apply_async(
|
poll_generation_task.apply_async(
|
||||||
args=[task.id],
|
args=[snapshot.id],
|
||||||
kwargs={"force_due": True, "owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": int(task.generation_attempt_no or 1)},
|
kwargs={"force_due": True, "owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": snapshot.generation_attempt_no},
|
||||||
queue=POLL_QUEUE,
|
queue=POLL_QUEUE,
|
||||||
countdown=0,
|
countdown=0,
|
||||||
)
|
)
|
||||||
await register_poll_active(
|
await register_poll_active(
|
||||||
task,
|
snapshot,
|
||||||
check_at=task.next_poll_at,
|
check_at=snapshot.next_poll_at,
|
||||||
next_poll_at=task.next_poll_at,
|
next_poll_at=snapshot.next_poll_at,
|
||||||
reason=f"{source}_has_provider_task_id",
|
reason=f"{source}_has_provider_task_id",
|
||||||
)
|
)
|
||||||
return "recover_poll_has_provider_id"
|
return "recover_poll_has_provider_id"
|
||||||
@@ -633,64 +702,84 @@ async def recover_one_generation_task(
|
|||||||
ChatGenerationPipelineStage.QUEUED.value,
|
ChatGenerationPipelineStage.QUEUED.value,
|
||||||
ChatGenerationPipelineStage.PREPARING.value,
|
ChatGenerationPipelineStage.PREPARING.value,
|
||||||
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
||||||
ChatGenerationPipelineStage.WAITING_REMOTE.value,
|
|
||||||
ChatGenerationPipelineStage.POLLING.value,
|
|
||||||
}
|
}
|
||||||
if task.pipeline_stage in recoverable_create_stages:
|
if task.pipeline_stage in recoverable_create_stages:
|
||||||
if task.pipeline_stage not in (
|
|
||||||
ChatGenerationPipelineStage.QUEUED.value,
|
|
||||||
ChatGenerationPipelineStage.PREPARING.value,
|
|
||||||
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
|
|
||||||
):
|
|
||||||
task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
|
task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
|
||||||
# 刷新更新时间形成创建队列保护窗口,避免 Beat 在任务尚未消费时每轮重复补投。
|
|
||||||
task.updated_at = current_time
|
task.updated_at = current_time
|
||||||
# Release the recovery row lock before writing an event through the
|
task_id = str(task.id)
|
||||||
# independent logging session or talking to the broker.
|
attempt_no = int(task.generation_attempt_no or 1)
|
||||||
|
generation_mode = str(task.generation_mode or "")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
await _remove_poll_active(_chat_registry_id(task))
|
await _remove_poll_active(
|
||||||
|
redis_owner_item_id(
|
||||||
|
GenerationOwnerType.CHAT_GENERATION_TASK.value,
|
||||||
|
task_id,
|
||||||
|
attempt_no,
|
||||||
|
)
|
||||||
|
)
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
task,
|
owner_type=GenerationOwnerType.CHAT_GENERATION_TASK.value,
|
||||||
|
owner_id=task_id,
|
||||||
|
generation_attempt_no=attempt_no,
|
||||||
|
generation_mode=generation_mode,
|
||||||
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value,
|
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value,
|
||||||
message=f"{source} 发现任务未超时且缺少 remote_result_url/供应商任务ID,恢复投递创建队列",
|
message=f"{source} 发现任务未超时且缺少 remote_result_url/供应商任务ID,恢复投递创建队列",
|
||||||
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
|
detail={
|
||||||
|
"pipeline_stage": ChatGenerationPipelineStage.QUEUED.value,
|
||||||
|
"payload": redis_payload,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
chatapi_create_generation_task.apply_async(
|
chatapi_create_generation_task.apply_async(
|
||||||
args=[task.id],
|
args=[task_id],
|
||||||
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": int(task.generation_attempt_no or 1)},
|
kwargs={
|
||||||
|
"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value,
|
||||||
|
"generation_attempt_no": attempt_no,
|
||||||
|
},
|
||||||
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
||||||
countdown=0,
|
countdown=0,
|
||||||
task_id=(
|
task_id=(
|
||||||
f"generation-create:{GenerationOwnerType.CHAT_GENERATION_TASK.value}:"
|
f"generation-create:{GenerationOwnerType.CHAT_GENERATION_TASK.value}:"
|
||||||
f"{task.id}:attempt:{int(task.generation_attempt_no or 1)}"
|
f"{task_id}:attempt:{attempt_no}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return "recover_create_no_remote_no_provider_before_deadline"
|
return "recover_create_no_remote_no_provider_before_deadline"
|
||||||
|
|
||||||
# result_ready 但没有 URL 是脏状态;未过 deadline 时回创建队列重新处理,过期上面已标记超时。
|
inconsistent_stages = {
|
||||||
if task.pipeline_stage == ChatGenerationPipelineStage.RESULT_READY.value:
|
ChatGenerationPipelineStage.WAITING_REMOTE.value,
|
||||||
task.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
|
ChatGenerationPipelineStage.POLLING.value,
|
||||||
task.updated_at = current_time
|
ChatGenerationPipelineStage.RESULT_READY.value,
|
||||||
|
ChatGenerationPipelineStage.RECOVERY_INCONSISTENT.value,
|
||||||
|
}
|
||||||
|
if task.pipeline_stage in inconsistent_stages:
|
||||||
|
original_stage = str(task.pipeline_stage or "")
|
||||||
|
task_id = str(task.id)
|
||||||
|
attempt_no = int(task.generation_attempt_no or 1)
|
||||||
|
generation_mode = str(task.generation_mode or "")
|
||||||
|
task.pipeline_stage = ChatGenerationPipelineStage.RECOVERY_INCONSISTENT.value
|
||||||
|
task.error_message = (
|
||||||
|
f"{source} 恢复证据异常:阶段 {original_stage} 缺少 remote_result_url 和供应商任务ID"
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await _remove_poll_active(_chat_registry_id(task))
|
await _remove_poll_active(
|
||||||
|
redis_owner_item_id(
|
||||||
|
GenerationOwnerType.CHAT_GENERATION_TASK.value,
|
||||||
|
task_id,
|
||||||
|
attempt_no,
|
||||||
|
)
|
||||||
|
)
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
task,
|
owner_type=GenerationOwnerType.CHAT_GENERATION_TASK.value,
|
||||||
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_ENQUEUE.value,
|
owner_id=task_id,
|
||||||
message=f"{source} 发现 result_ready 但缺少 remote_result_url,未超时,恢复投递创建队列",
|
generation_attempt_no=attempt_no,
|
||||||
detail={"pipeline_stage": task.pipeline_stage, "payload": redis_payload},
|
generation_mode=generation_mode,
|
||||||
|
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_INCONSISTENT.value,
|
||||||
|
from_stage=original_stage,
|
||||||
|
to_stage=ChatGenerationPipelineStage.RECOVERY_INCONSISTENT.value,
|
||||||
|
message=f"{source} 发现恢复证据异常,已隔离且不重新创建供应商任务",
|
||||||
|
detail={"payload": redis_payload},
|
||||||
)
|
)
|
||||||
chatapi_create_generation_task.apply_async(
|
return "quarantine_inconsistent_recovery_evidence"
|
||||||
args=[task.id],
|
|
||||||
kwargs={"owner_type": GenerationOwnerType.CHAT_GENERATION_TASK.value, "generation_attempt_no": int(task.generation_attempt_no or 1)},
|
|
||||||
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
|
|
||||||
countdown=0,
|
|
||||||
task_id=(
|
|
||||||
f"generation-create:{GenerationOwnerType.CHAT_GENERATION_TASK.value}:"
|
|
||||||
f"{task.id}:attempt:{int(task.generation_attempt_no or 1)}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return "recover_create_result_ready_no_url_before_deadline"
|
|
||||||
|
|
||||||
return f"skip_stage_{task.pipeline_stage}"
|
return f"skip_stage_{task.pipeline_stage}"
|
||||||
|
|
||||||
@@ -919,6 +1008,7 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
|
|||||||
"waiting_remote",
|
"waiting_remote",
|
||||||
"polling",
|
"polling",
|
||||||
"result_ready",
|
"result_ready",
|
||||||
|
"recovery_inconsistent",
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -971,6 +971,13 @@ async def run_image_prompt_optimize(
|
|||||||
user_id=user_id_value,
|
user_id=user_id_value,
|
||||||
references=references,
|
references=references,
|
||||||
gen_type="image",
|
gen_type="image",
|
||||||
|
log_module=module_value,
|
||||||
|
log_step="hot_opening_image_prompt_optimize",
|
||||||
|
log_project_id=project_id_value,
|
||||||
|
log_task_id=step_id_value,
|
||||||
|
log_owner_type="module_generation_step",
|
||||||
|
log_owner_id=step_id_value,
|
||||||
|
generation_attempt_no=expected_step_version,
|
||||||
)
|
)
|
||||||
if execution_guard is not None:
|
if execution_guard is not None:
|
||||||
await execution_guard()
|
await execution_guard()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -15,7 +16,6 @@ from app.enums.hot_opening_replicate import HotOpeningLogEventEnum, HotOpeningRe
|
|||||||
from app.enums.shot_replicate import ModuleCodeEnum as ShotModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
|
from app.enums.shot_replicate import ModuleCodeEnum as ShotModuleCodeEnum, ShotReplicateLogEventEnum, ShotReplicateRemoteActionEnum
|
||||||
from app.services.operation_log_service import log_ai_model_event
|
from app.services.operation_log_service import log_ai_model_event
|
||||||
from app.enums.common import (
|
from app.enums.common import (
|
||||||
VIDEO_SCHEMA_CONFIG_DATABASE_SOURCE,
|
|
||||||
VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE,
|
VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE,
|
||||||
VIDEO_SCHEMA_CONFIG_VERSION,
|
VIDEO_SCHEMA_CONFIG_VERSION,
|
||||||
VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN,
|
VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN,
|
||||||
@@ -1477,6 +1477,7 @@ def _log_video_prompt_ai_event(
|
|||||||
event_status: str,
|
event_status: str,
|
||||||
config: ModelConfig,
|
config: ModelConfig,
|
||||||
trace_id: str | None,
|
trace_id: str | None,
|
||||||
|
call_id: str,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
project_id: str | None,
|
project_id: str | None,
|
||||||
step_id: str | None,
|
step_id: str | None,
|
||||||
@@ -1489,29 +1490,60 @@ def _log_video_prompt_ai_event(
|
|||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
detail: dict[str, Any] | None = None,
|
detail: dict[str, Any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
common = {
|
||||||
|
"source": LogSourceEnum.REMOTE_API.value,
|
||||||
|
"module": module,
|
||||||
|
"step_code": "video_prompt_generate",
|
||||||
|
"call_id": call_id,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"project_id": project_id,
|
||||||
|
"task_id": step_id,
|
||||||
|
"step_id": step_id,
|
||||||
|
"owner_type": "module_generation_step",
|
||||||
|
"owner_id": step_id or project_id,
|
||||||
|
"remote_action": action,
|
||||||
|
"remote_request_id": remote_request_id,
|
||||||
|
"model_config_id": str(config.id),
|
||||||
|
"model_config_name": config.name,
|
||||||
|
"model_name": config.model_name,
|
||||||
|
"provider": config.provider,
|
||||||
|
"api_base": config.api_base,
|
||||||
|
"http_status": http_status,
|
||||||
|
}
|
||||||
|
normalized_status = str(event_status or "").lower()
|
||||||
|
if normalized_status == str(LogEventStatusEnum.STARTED.value).lower():
|
||||||
log_ai_model_event(
|
log_ai_model_event(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
|
event_phase="REQUEST",
|
||||||
event_status=event_status,
|
event_status=event_status,
|
||||||
source=LogSourceEnum.REMOTE_API.value,
|
|
||||||
module=module,
|
|
||||||
trace_id=trace_id,
|
|
||||||
user_id=user_id,
|
|
||||||
project_id=project_id,
|
|
||||||
step_id=step_id,
|
|
||||||
remote_action=action,
|
|
||||||
remote_request_id=remote_request_id,
|
|
||||||
model_config_id=str(config.id),
|
|
||||||
model_config_name=config.name,
|
|
||||||
model_name=config.model_name,
|
|
||||||
provider=config.provider,
|
|
||||||
api_base=config.api_base,
|
|
||||||
http_status=http_status,
|
|
||||||
request=request_data,
|
request=request_data,
|
||||||
|
message=message,
|
||||||
|
detail=detail,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if response_data is not None:
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type=event_type,
|
||||||
|
event_phase="RESPONSE",
|
||||||
|
event_status=event_status,
|
||||||
response=response_data,
|
response=response_data,
|
||||||
token_usage=token_usage,
|
token_usage=token_usage,
|
||||||
message=message,
|
message=message,
|
||||||
detail=detail,
|
detail=detail,
|
||||||
error=error,
|
error=error if normalized_status == str(LogEventStatusEnum.FAILED.value).lower() else None,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
if normalized_status == str(LogEventStatusEnum.FAILED.value).lower() or error:
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type=event_type,
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status=LogEventStatusEnum.FAILED.value,
|
||||||
|
message=message,
|
||||||
|
detail=detail,
|
||||||
|
error=error or "AI model call failed",
|
||||||
|
**common,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _select_model_config(db: AsyncSession) -> ModelConfig | None:
|
async def _select_model_config(db: AsyncSession) -> ModelConfig | None:
|
||||||
@@ -1536,6 +1568,7 @@ async def optimize_hot_opening_video_prompt(
|
|||||||
step_id: str | None = None,
|
step_id: str | None = None,
|
||||||
trace_id: str | None = None,
|
trace_id: str | None = None,
|
||||||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||||||
|
call_id = generate_id()
|
||||||
duration = int(video_config["duration"])
|
duration = int(video_config["duration"])
|
||||||
from app.utils.media import media_to_base64, get_llm_media_as_base64
|
from app.utils.media import media_to_base64, get_llm_media_as_base64
|
||||||
use_base64 = await get_llm_media_as_base64(db)
|
use_base64 = await get_llm_media_as_base64(db)
|
||||||
@@ -1559,9 +1592,22 @@ async def optimize_hot_opening_video_prompt(
|
|||||||
# result = ensure_negative_prompt(ensure_flow_matches_time_plan(ensure_top_keys(fill_none_with_wu(result)), duration))
|
# result = ensure_negative_prompt(ensure_flow_matches_time_plan(ensure_top_keys(fill_none_with_wu(result)), duration))
|
||||||
# return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
# return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
|
||||||
|
|
||||||
config = await _select_model_config(db)
|
config_row = await _select_model_config(db)
|
||||||
|
config = (
|
||||||
|
SimpleNamespace(
|
||||||
|
id=str(config_row.id),
|
||||||
|
name=str(config_row.name or ""),
|
||||||
|
provider=str(config_row.provider or ""),
|
||||||
|
api_base=str(config_row.api_base or ""),
|
||||||
|
api_key=str(config_row.api_key or ""),
|
||||||
|
model_name=str(config_row.model_name or ""),
|
||||||
|
)
|
||||||
|
if config_row is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
# All module/project claims are committed by the caller. Release this
|
# All module/project claims are committed by the caller. Release this
|
||||||
# configuration read transaction before the remote model request.
|
# configuration read transaction before the remote model request and use
|
||||||
|
# only the scalar snapshot afterwards.
|
||||||
await db.commit()
|
await db.commit()
|
||||||
if not config:
|
if not config:
|
||||||
result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config, schema_config_snapshot)
|
result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config, schema_config_snapshot)
|
||||||
@@ -1602,6 +1648,7 @@ async def optimize_hot_opening_video_prompt(
|
|||||||
}
|
}
|
||||||
started_event, remote_action = _video_prompt_remote_event(module, started=True)
|
started_event, remote_action = _video_prompt_remote_event(module, started=True)
|
||||||
_log_video_prompt_ai_event(
|
_log_video_prompt_ai_event(
|
||||||
|
call_id=call_id,
|
||||||
module=module,
|
module=module,
|
||||||
event_type=started_event,
|
event_type=started_event,
|
||||||
action=remote_action,
|
action=remote_action,
|
||||||
@@ -1625,6 +1672,7 @@ async def optimize_hot_opening_video_prompt(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
failed_event, remote_action = _video_prompt_remote_event(module)
|
failed_event, remote_action = _video_prompt_remote_event(module)
|
||||||
_log_video_prompt_ai_event(
|
_log_video_prompt_ai_event(
|
||||||
|
call_id=call_id,
|
||||||
module=module,
|
module=module,
|
||||||
event_type=failed_event,
|
event_type=failed_event,
|
||||||
action=remote_action,
|
action=remote_action,
|
||||||
@@ -1645,6 +1693,7 @@ async def optimize_hot_opening_video_prompt(
|
|||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
failed_event, remote_action = _video_prompt_remote_event(module)
|
failed_event, remote_action = _video_prompt_remote_event(module)
|
||||||
_log_video_prompt_ai_event(
|
_log_video_prompt_ai_event(
|
||||||
|
call_id=call_id,
|
||||||
module=module,
|
module=module,
|
||||||
event_type=failed_event,
|
event_type=failed_event,
|
||||||
action=remote_action,
|
action=remote_action,
|
||||||
@@ -1671,6 +1720,7 @@ async def optimize_hot_opening_video_prompt(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
parse_event, remote_action = _video_prompt_remote_event(module, empty="content 为空" in str(exc), parse_failed="content 为空" not in str(exc))
|
parse_event, remote_action = _video_prompt_remote_event(module, empty="content 为空" in str(exc), parse_failed="content 为空" not in str(exc))
|
||||||
_log_video_prompt_ai_event(
|
_log_video_prompt_ai_event(
|
||||||
|
call_id=call_id,
|
||||||
module=module,
|
module=module,
|
||||||
event_type=parse_event,
|
event_type=parse_event,
|
||||||
action=remote_action,
|
action=remote_action,
|
||||||
@@ -1721,6 +1771,7 @@ async def optimize_hot_opening_video_prompt(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
parse_event, remote_action = _video_prompt_remote_event(module, parse_failed=True)
|
parse_event, remote_action = _video_prompt_remote_event(module, parse_failed=True)
|
||||||
_log_video_prompt_ai_event(
|
_log_video_prompt_ai_event(
|
||||||
|
call_id=call_id,
|
||||||
module=module,
|
module=module,
|
||||||
event_type=parse_event,
|
event_type=parse_event,
|
||||||
action=remote_action,
|
action=remote_action,
|
||||||
@@ -1740,6 +1791,7 @@ async def optimize_hot_opening_video_prompt(
|
|||||||
raise
|
raise
|
||||||
success_event, remote_action = _video_prompt_remote_event(module, success=True)
|
success_event, remote_action = _video_prompt_remote_event(module, success=True)
|
||||||
_log_video_prompt_ai_event(
|
_log_video_prompt_ai_event(
|
||||||
|
call_id=call_id,
|
||||||
module=module,
|
module=module,
|
||||||
event_type=success_event,
|
event_type=success_event,
|
||||||
action=remote_action,
|
action=remote_action,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -16,7 +16,8 @@ from app.enums.generation_provider import (
|
|||||||
)
|
)
|
||||||
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX
|
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX
|
||||||
from app.models.image_engine import ImageEngine
|
from app.models.image_engine import ImageEngine
|
||||||
from app.services.log_config import LOG_DATE_FORMAT, LOG_DIR, encrypt_data, is_enabled
|
from app.services.operation_log_service import build_exception_detail, log_ai_model_event
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
from app.types.generation.provider import (
|
from app.types.generation.provider import (
|
||||||
ImageProviderBatchResult,
|
ImageProviderBatchResult,
|
||||||
ImageProviderItem,
|
ImageProviderItem,
|
||||||
@@ -59,49 +60,27 @@ class ImageProviderError(RuntimeError):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _log_image_request(engine: ProviderImageEngineLike, record_id: str, request_data: dict):
|
|
||||||
if not is_enabled():
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
os.makedirs(LOG_DIR, exist_ok=True)
|
|
||||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
|
||||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
|
||||||
request_str = json.dumps(request_data, ensure_ascii=False)
|
|
||||||
request_encrypted = encrypt_data(request_data, True)
|
|
||||||
entry = {
|
|
||||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
"type": "image_gen_request",
|
|
||||||
"engine": engine.name,
|
|
||||||
"model": engine.model_name,
|
|
||||||
"record_id": record_id,
|
|
||||||
"request": request_encrypted,
|
|
||||||
"request_length": len(request_str),
|
|
||||||
}
|
|
||||||
with open(log_file, "a", encoding="utf-8") as file:
|
|
||||||
file.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
def _provider_log_context(engine, record, *, call_id: str, step_code: str) -> dict:
|
||||||
def _log_image_response(record_id: str, response_data: dict, error: str | None = None):
|
generation_mode = str(getattr(record, "generation_mode", "") or "generation_record")
|
||||||
if not is_enabled():
|
owner_type = "chat_generation_task" if generation_mode != "generation_record" else "generation_record"
|
||||||
return
|
return {
|
||||||
try:
|
"module": generation_mode,
|
||||||
os.makedirs(LOG_DIR, exist_ok=True)
|
"step_code": step_code,
|
||||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
"call_id": call_id,
|
||||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
"source": "app.services.image_gen",
|
||||||
response_encrypted = encrypt_data(response_data, True) if response_data else ""
|
"user_id": str(getattr(record, "user_id", "") or "") or None,
|
||||||
entry = {
|
"project_id": str(getattr(record, "project_id", "") or "") or None,
|
||||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"task_id": str(getattr(record, "id", "") or "") or None,
|
||||||
"type": "image_gen_response",
|
"owner_type": owner_type,
|
||||||
"record_id": record_id,
|
"owner_id": str(getattr(record, "id", "") or "") or None,
|
||||||
"response": response_encrypted,
|
"generation_attempt_no": int(getattr(record, "generation_attempt_no", 1) or 1),
|
||||||
"error": error,
|
"model_config_id": str(getattr(engine, "id", "") or "") or None,
|
||||||
|
"model_config_name": str(getattr(engine, "name", "") or "") or None,
|
||||||
|
"model_name": str(getattr(engine, "model_name", "") or "") or None,
|
||||||
|
"provider": str(getattr(engine, "provider", "") or "") or None,
|
||||||
|
"api_base": str(getattr(engine, "api_base", "") or "") or None,
|
||||||
}
|
}
|
||||||
with open(log_file, "a", encoding="utf-8") as file:
|
|
||||||
file.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def get_active_image_engine(db: AsyncSession) -> ImageEngine:
|
async def get_active_image_engine(db: AsyncSession) -> ImageEngine:
|
||||||
@@ -321,7 +300,18 @@ def submit_image_task(
|
|||||||
)
|
)
|
||||||
request_sdk_payload["stream"] = False
|
request_sdk_payload["stream"] = False
|
||||||
|
|
||||||
_log_image_request(engine, record.id, request_log_payload)
|
call_id = generate_id()
|
||||||
|
started = time.perf_counter()
|
||||||
|
api_step = "image_sync_batch_create" if count > 1 else "image_sync_create"
|
||||||
|
log_context = _provider_log_context(engine, record, call_id=call_id, step_code=api_step)
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="REQUEST",
|
||||||
|
event_phase="REQUEST",
|
||||||
|
event_status="started",
|
||||||
|
remote_action=api_step,
|
||||||
|
request=request_log_payload,
|
||||||
|
**log_context,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = client.images.generate(**request_sdk_payload)
|
result = client.images.generate(**request_sdk_payload)
|
||||||
@@ -386,7 +376,16 @@ def submit_image_task(
|
|||||||
"total_tokens": total_tokens,
|
"total_tokens": total_tokens,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_log_image_response(record.id, response_data)
|
log_ai_model_event(
|
||||||
|
event_type="RESPONSE",
|
||||||
|
event_phase="RESPONSE",
|
||||||
|
event_status="success",
|
||||||
|
remote_action=api_step,
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
response=response_data,
|
||||||
|
token_usage=response_data.get("usage"),
|
||||||
|
**log_context,
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"items": items,
|
"items": items,
|
||||||
"model": str(response_data["model"] or ""),
|
"model": str(response_data["model"] or ""),
|
||||||
@@ -404,7 +403,18 @@ def submit_image_task(
|
|||||||
provider_error.error_code,
|
provider_error.error_code,
|
||||||
provider_error.safe_message,
|
provider_error.safe_message,
|
||||||
)
|
)
|
||||||
_log_image_response(record.id, provider_error.as_dict(), provider_error.safe_message)
|
log_ai_model_event(
|
||||||
|
event_type="ERROR",
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status="failed",
|
||||||
|
remote_action=api_step,
|
||||||
|
http_status=provider_error.http_status,
|
||||||
|
remote_request_id=provider_error.provider_request_id,
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
detail=build_exception_detail(exc, provider_error.as_dict()),
|
||||||
|
error=provider_error.safe_message,
|
||||||
|
**log_context,
|
||||||
|
)
|
||||||
raise provider_error from exc
|
raise provider_error from exc
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import time
|
||||||
from datetime import datetime
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -10,47 +10,13 @@ from app.config import settings
|
|||||||
from app.models.model_config import ModelConfig
|
from app.models.model_config import ModelConfig
|
||||||
from app.models.token_usage import TokenUsage
|
from app.models.token_usage import TokenUsage
|
||||||
from app.utils.id_gen import generate_id
|
from app.utils.id_gen import generate_id
|
||||||
from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
|
from app.services.operation_log_service import build_exception_detail, log_ai_model_event
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_for_log(data):
|
|
||||||
"""Replace base64 data URIs with placeholder for readable logs."""
|
|
||||||
if isinstance(data, str):
|
|
||||||
if data.startswith("data:") and ";base64," in data:
|
|
||||||
return "[base64 image data]"
|
|
||||||
return data
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return {k: _sanitize_for_log(v) for k, v in data.items()}
|
|
||||||
if isinstance(data, list):
|
|
||||||
return [_sanitize_for_log(item) for item in data]
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def _log_ai_request_response(config, request_data: dict, response_data: dict | None, error: str | None = None):
|
class LLMProviderCallError(RuntimeError):
|
||||||
"""Log AI model request/response to log/AiModel/YYYY-MM-DD.log"""
|
"""Remote model call or response validation failed and may use fallback."""
|
||||||
if not is_enabled():
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
os.makedirs(LOG_DIR, exist_ok=True)
|
|
||||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
|
||||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
|
||||||
request_encrypted = encrypt_data(_sanitize_for_log(request_data), True)
|
|
||||||
response_encrypted = encrypt_data(_sanitize_for_log(response_data), True) if response_data else ""
|
|
||||||
entry = {
|
|
||||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
"model_name": config.name,
|
|
||||||
"model_id": config.model_name,
|
|
||||||
"provider": config.provider,
|
|
||||||
"api_base": config.api_base,
|
|
||||||
"request": request_encrypted,
|
|
||||||
"response": response_encrypted,
|
|
||||||
"error": error,
|
|
||||||
}
|
|
||||||
with open(log_file, "a", encoding="utf-8") as f:
|
|
||||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
MOCK_OPTIMIZED_PROMPTS = {
|
MOCK_OPTIMIZED_PROMPTS = {
|
||||||
"直播": "专业直播间场景,45度斜角机位,暖色柔光打光,主播居中构图,背景虚化处理,产品特写切换流畅,镜头推进节奏感强,画面色彩饱和度高,适合电商直播推广视频。",
|
"直播": "专业直播间场景,45度斜角机位,暖色柔光打光,主播居中构图,背景虚化处理,产品特写切换流畅,镜头推进节奏感强,画面色彩饱和度高,适合电商直播推广视频。",
|
||||||
@@ -92,9 +58,17 @@ async def optimize_prompt(
|
|||||||
duration: int | None = None,
|
duration: int | None = None,
|
||||||
image_size: str | None = None,
|
image_size: str | None = None,
|
||||||
image_proportion: str | None = None,
|
image_proportion: str | None = None,
|
||||||
image_px: str | None | None = None,
|
image_px: str | None = None,
|
||||||
references: list[dict] | None = None,
|
references: list[dict] | None = None,
|
||||||
gen_type: str = "video",
|
gen_type: str = "video",
|
||||||
|
*,
|
||||||
|
log_module: str = "generation_ai",
|
||||||
|
log_step: str = "prompt_optimize",
|
||||||
|
log_project_id: str | None = None,
|
||||||
|
log_task_id: str | None = None,
|
||||||
|
log_owner_type: str | None = None,
|
||||||
|
log_owner_id: str | None = None,
|
||||||
|
generation_attempt_no: int | None = None,
|
||||||
) -> tuple[str, dict]:
|
) -> tuple[str, dict]:
|
||||||
"""Optimize user prompt using LLM. Returns (optimized_text, token_usage_dict)."""
|
"""Optimize user prompt using LLM. Returns (optimized_text, token_usage_dict)."""
|
||||||
|
|
||||||
@@ -103,9 +77,22 @@ async def optimize_prompt(
|
|||||||
.where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None))
|
.where(ModelConfig.is_active == True, ModelConfig.deleted_at.is_(None))
|
||||||
.order_by(ModelConfig.priority.desc())
|
.order_by(ModelConfig.priority.desc())
|
||||||
)
|
)
|
||||||
configs = list(result.scalars().all())
|
configs = [
|
||||||
# Release the read transaction before the external LLM request. Callers
|
SimpleNamespace(
|
||||||
# must commit their business claim before invoking optimize_prompt.
|
id=item.id,
|
||||||
|
name=item.name,
|
||||||
|
provider=item.provider,
|
||||||
|
api_base=item.api_base,
|
||||||
|
api_key=item.api_key,
|
||||||
|
model_name=item.model_name,
|
||||||
|
max_tokens=item.max_tokens,
|
||||||
|
temperature=item.temperature,
|
||||||
|
)
|
||||||
|
for item in result.scalars().all()
|
||||||
|
]
|
||||||
|
# Release the read transaction before the external LLM request. Only
|
||||||
|
# plain scalar snapshots are used afterwards, so expire_on_commit does
|
||||||
|
# not trigger an ORM refresh while the provider request is in flight.
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
if configs:
|
if configs:
|
||||||
@@ -122,8 +109,15 @@ async def optimize_prompt(
|
|||||||
image_size=image_size,
|
image_size=image_size,
|
||||||
image_proportion=image_proportion,
|
image_proportion=image_proportion,
|
||||||
image_px=image_px,
|
image_px=image_px,
|
||||||
|
log_module=log_module,
|
||||||
|
log_step=log_step,
|
||||||
|
log_project_id=log_project_id,
|
||||||
|
log_task_id=log_task_id,
|
||||||
|
log_owner_type=log_owner_type,
|
||||||
|
log_owner_id=log_owner_id,
|
||||||
|
generation_attempt_no=generation_attempt_no,
|
||||||
)
|
)
|
||||||
except Exception:
|
except LLMProviderCallError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 所有真实模型都失败,降级到 mock
|
# 所有真实模型都失败,降级到 mock
|
||||||
@@ -156,7 +150,15 @@ async def _call_openai_compatible(
|
|||||||
gen_type: str = "video",
|
gen_type: str = "video",
|
||||||
image_size: str | None = None,
|
image_size: str | None = None,
|
||||||
image_proportion: str | None = None,
|
image_proportion: str | None = None,
|
||||||
image_px: str | None | None = None,
|
image_px: str | None = None,
|
||||||
|
*,
|
||||||
|
log_module: str = "generation_ai",
|
||||||
|
log_step: str = "prompt_optimize",
|
||||||
|
log_project_id: str | None = None,
|
||||||
|
log_task_id: str | None = None,
|
||||||
|
log_owner_type: str | None = None,
|
||||||
|
log_owner_id: str | None = None,
|
||||||
|
generation_attempt_no: int | None = None,
|
||||||
) -> tuple[str, dict]:
|
) -> tuple[str, dict]:
|
||||||
"""Call an OpenAI-compatible API to optimize the prompt. Returns (content, token_usage)."""
|
"""Call an OpenAI-compatible API to optimize the prompt. Returns (content, token_usage)."""
|
||||||
system_prompt = None
|
system_prompt = None
|
||||||
@@ -321,14 +323,33 @@ async def _call_openai_compatible(
|
|||||||
"max_tokens": config.max_tokens,
|
"max_tokens": config.max_tokens,
|
||||||
"temperature": config.temperature,
|
"temperature": config.temperature,
|
||||||
}
|
}
|
||||||
# Build log-friendly request data (image paths instead of base64)
|
call_id = generate_id()
|
||||||
if log_user_message:
|
started = time.perf_counter()
|
||||||
log_request_data = {**request_data, "messages": [
|
common_log = {
|
||||||
{"role": "system", "content": system_prompt},
|
"module": log_module,
|
||||||
log_user_message,
|
"step_code": log_step,
|
||||||
]}
|
"call_id": call_id,
|
||||||
else:
|
"source": "app.services.llm",
|
||||||
log_request_data = request_data
|
"user_id": user_id,
|
||||||
|
"project_id": log_project_id,
|
||||||
|
"task_id": log_task_id,
|
||||||
|
"owner_type": log_owner_type,
|
||||||
|
"owner_id": log_owner_id,
|
||||||
|
"generation_attempt_no": generation_attempt_no,
|
||||||
|
"model_config_id": config.id,
|
||||||
|
"model_config_name": config.name,
|
||||||
|
"model_name": config.model_name,
|
||||||
|
"provider": config.provider,
|
||||||
|
"api_base": config.api_base,
|
||||||
|
"remote_action": "chat_completions",
|
||||||
|
}
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="REQUEST",
|
||||||
|
event_phase="REQUEST",
|
||||||
|
event_status="started",
|
||||||
|
request=request_data,
|
||||||
|
**common_log,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"{config.api_base}/chat/completions",
|
f"{config.api_base}/chat/completions",
|
||||||
@@ -338,28 +359,80 @@ async def _call_openai_compatible(
|
|||||||
},
|
},
|
||||||
json=request_data,
|
json=request_data,
|
||||||
)
|
)
|
||||||
|
latency_ms = int((time.perf_counter() - started) * 1000)
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
error_body = response.text
|
error_body = response.text
|
||||||
_log_ai_request_response(config, log_request_data, None, error=f"HTTP {response.status_code}: {error_body}")
|
log_ai_model_event(
|
||||||
raise RuntimeError(f"HTTP {response.status_code}: {error_body}")
|
event_type="RESPONSE",
|
||||||
|
event_phase="RESPONSE",
|
||||||
|
event_status="failed",
|
||||||
|
http_status=response.status_code,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
response={"body": error_body},
|
||||||
|
error=f"HTTP {response.status_code}",
|
||||||
|
**common_log,
|
||||||
|
)
|
||||||
|
error = LLMProviderCallError(f"HTTP {response.status_code}: {error_body}")
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="ERROR",
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status="failed",
|
||||||
|
http_status=response.status_code,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
detail=build_exception_detail(error),
|
||||||
|
error=str(error),
|
||||||
|
**common_log,
|
||||||
|
)
|
||||||
|
raise error
|
||||||
data = response.json()
|
data = response.json()
|
||||||
except RuntimeError:
|
log_ai_model_event(
|
||||||
|
event_type="RESPONSE",
|
||||||
|
event_phase="RESPONSE",
|
||||||
|
event_status="success",
|
||||||
|
http_status=response.status_code,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
response=data,
|
||||||
|
token_usage=data.get("usage") if isinstance(data, dict) else None,
|
||||||
|
**common_log,
|
||||||
|
)
|
||||||
|
except LLMProviderCallError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as exc:
|
||||||
_log_ai_request_response(config, log_request_data, None, error=str(e))
|
latency_ms = int((time.perf_counter() - started) * 1000)
|
||||||
raise RuntimeError(f"{type(e).__name__}: {e}")
|
log_ai_model_event(
|
||||||
|
event_type="ERROR",
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status="failed",
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
detail=build_exception_detail(exc),
|
||||||
|
error=str(exc),
|
||||||
|
**common_log,
|
||||||
|
)
|
||||||
|
raise LLMProviderCallError(f"{type(exc).__name__}: {exc}") from exc
|
||||||
|
|
||||||
# Log request/response
|
try:
|
||||||
_log_ai_request_response(config, log_request_data, data)
|
|
||||||
|
|
||||||
# Record token usage
|
|
||||||
usage = data.get("usage", {})
|
usage = data.get("usage", {})
|
||||||
input_tokens = usage.get("prompt_tokens", 0)
|
input_tokens = int(usage.get("prompt_tokens", 0) or 0)
|
||||||
output_tokens = usage.get("completion_tokens", 0)
|
output_tokens = int(usage.get("completion_tokens", 0) or 0)
|
||||||
total_tokens = usage.get("total_tokens", input_tokens + output_tokens)
|
total_tokens = int(usage.get("total_tokens", input_tokens + output_tokens) or 0)
|
||||||
|
content = data["choices"][0]["message"]["content"].strip()
|
||||||
|
if not content:
|
||||||
|
raise ValueError("模型未返回有效提示词")
|
||||||
|
except Exception as exc:
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="ERROR",
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status="failed",
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
detail=build_exception_detail(exc, {"stage": "response_validation"}),
|
||||||
|
error=str(exc),
|
||||||
|
**common_log,
|
||||||
|
)
|
||||||
|
raise LLMProviderCallError(f"模型响应解析失败: {exc}") from exc
|
||||||
|
|
||||||
token_usage_id = None
|
token_usage_id = None
|
||||||
if db is not None:
|
if db is not None:
|
||||||
|
try:
|
||||||
token_usage_id = generate_id()
|
token_usage_id = generate_id()
|
||||||
record = TokenUsage(
|
record = TokenUsage(
|
||||||
id=token_usage_id,
|
id=token_usage_id,
|
||||||
@@ -368,17 +441,35 @@ async def _call_openai_compatible(
|
|||||||
input_tokens=input_tokens,
|
input_tokens=input_tokens,
|
||||||
output_tokens=output_tokens,
|
output_tokens=output_tokens,
|
||||||
total_tokens=total_tokens,
|
total_tokens=total_tokens,
|
||||||
|
source_module=log_module,
|
||||||
|
source_step_code=log_step,
|
||||||
|
owner_type=log_owner_type,
|
||||||
|
owner_id=log_owner_id,
|
||||||
)
|
)
|
||||||
db.add(record)
|
db.add(record)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
except Exception as exc:
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="ERROR",
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status="failed",
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
detail=build_exception_detail(exc, {"stage": "token_usage_persistence"}),
|
||||||
|
error=str(exc),
|
||||||
|
**common_log,
|
||||||
|
)
|
||||||
|
# A local transaction failure must not call a second provider after
|
||||||
|
# the first provider has already returned a valid response.
|
||||||
|
raise
|
||||||
|
|
||||||
content = data["choices"][0]["message"]["content"].strip()
|
|
||||||
token_usage = {
|
token_usage = {
|
||||||
"token_usage_id": token_usage_id,
|
"token_usage_id": token_usage_id,
|
||||||
"model_config_id": config.id,
|
"model_config_id": config.id,
|
||||||
"model_config_name": config.name,
|
"model_config_name": config.name,
|
||||||
"model_provider": config.provider,
|
"model_provider": config.provider,
|
||||||
"model_name": config.model_name,
|
"model_name": config.model_name,
|
||||||
|
"source_module": log_module,
|
||||||
|
"source_step_code": log_step,
|
||||||
"input_tokens": input_tokens,
|
"input_tokens": input_tokens,
|
||||||
"output_tokens": output_tokens,
|
"output_tokens": output_tokens,
|
||||||
"total_tokens": total_tokens,
|
"total_tokens": total_tokens,
|
||||||
|
|||||||
@@ -61,8 +61,13 @@ def log_module_prompt_event(
|
|||||||
step_id=step_id,
|
step_id=step_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
message=f"模块 AI 请求:{prompt_type}",
|
message=f"模块 AI 步骤:{prompt_type}",
|
||||||
detail={"prompt_type": prompt_type, "request": request or {}, "response": response or {}, "token_usage": token_usage or {}},
|
detail={
|
||||||
|
"prompt_type": prompt_type,
|
||||||
|
"has_request": request is not None,
|
||||||
|
"has_response": response is not None,
|
||||||
|
"token_usage": token_usage or {},
|
||||||
|
},
|
||||||
error=error,
|
error=error,
|
||||||
event_status=LogEventStatusEnum.FAILED.value if error else LogEventStatusEnum.SUCCESS.value,
|
event_status=LogEventStatusEnum.FAILED.value if error else LogEventStatusEnum.SUCCESS.value,
|
||||||
source=LogSourceEnum.SERVICE.value,
|
source=LogSourceEnum.SERVICE.value,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ MODULE_GENERATION_LOG_ROOT = os.path.join(LOG_BASE_DIR, "ModuleGeneration")
|
|||||||
AI_MODEL_LOG_ROOT = LOG_DIR
|
AI_MODEL_LOG_ROOT = LOG_DIR
|
||||||
SENSITIVE_KEY_PATTERNS = (
|
SENSITIVE_KEY_PATTERNS = (
|
||||||
"secret",
|
"secret",
|
||||||
"token",
|
|
||||||
"authorization",
|
"authorization",
|
||||||
"cookie",
|
"cookie",
|
||||||
"credential",
|
"credential",
|
||||||
@@ -30,9 +29,28 @@ SENSITIVE_KEY_PATTERNS = (
|
|||||||
"access_key",
|
"access_key",
|
||||||
"api_key",
|
"api_key",
|
||||||
"apikey",
|
"apikey",
|
||||||
"security-token",
|
"security_token",
|
||||||
"x-tos-security-token",
|
|
||||||
)
|
)
|
||||||
|
SENSITIVE_TOKEN_KEYS = {
|
||||||
|
"token",
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
"bearer_token",
|
||||||
|
"security_token",
|
||||||
|
"x_tos_security_token",
|
||||||
|
}
|
||||||
|
FILE_BASE64_KEYS = {
|
||||||
|
"b64_json",
|
||||||
|
"file_data",
|
||||||
|
"file_base64",
|
||||||
|
"content_base64",
|
||||||
|
"image_base64",
|
||||||
|
"video_base64",
|
||||||
|
"audio_base64",
|
||||||
|
}
|
||||||
|
FILE_DATA_URI_MIME_PREFIXES = ("image/", "video/", "audio/")
|
||||||
|
FILE_DATA_URI_MIME_TYPES = {"application/pdf", "application/octet-stream"}
|
||||||
|
FILE_BASE64_PREVIEW_CHARS = 30
|
||||||
|
|
||||||
|
|
||||||
def _safe_name(value: str | None, default: str = "unknown") -> str:
|
def _safe_name(value: str | None, default: str = "unknown") -> str:
|
||||||
@@ -49,7 +67,41 @@ def _mask_string(value: str) -> str:
|
|||||||
|
|
||||||
def _is_sensitive_key(key: str) -> bool:
|
def _is_sensitive_key(key: str) -> bool:
|
||||||
lower = str(key).replace("-", "_").lower()
|
lower = str(key).replace("-", "_").lower()
|
||||||
return lower == "sign" or any(pattern in lower for pattern in SENSITIVE_KEY_PATTERNS)
|
if lower == "sign" or lower in SENSITIVE_TOKEN_KEYS:
|
||||||
|
return True
|
||||||
|
return any(pattern in lower for pattern in SENSITIVE_KEY_PATTERNS)
|
||||||
|
|
||||||
|
|
||||||
|
def _decoded_base64_size(value: str) -> int:
|
||||||
|
compact = "".join(value.split())
|
||||||
|
if not compact:
|
||||||
|
return 0
|
||||||
|
padding = 2 if compact.endswith("==") else (1 if compact.endswith("=") else 0)
|
||||||
|
return max(0, (len(compact) * 3) // 4 - padding)
|
||||||
|
|
||||||
|
|
||||||
|
def _file_base64_preview(value: str, key_path: tuple[str, ...]) -> str | None:
|
||||||
|
data_uri = re.match(r"^data:([^;,]+);base64,(.*)$", value, flags=re.IGNORECASE | re.DOTALL)
|
||||||
|
prefix = ""
|
||||||
|
payload = value
|
||||||
|
is_file = False
|
||||||
|
if data_uri:
|
||||||
|
mime = str(data_uri.group(1) or "").lower()
|
||||||
|
is_file = mime.startswith(FILE_DATA_URI_MIME_PREFIXES) or mime in FILE_DATA_URI_MIME_TYPES
|
||||||
|
prefix = value[: value.find(",") + 1]
|
||||||
|
payload = data_uri.group(2)
|
||||||
|
elif key_path and key_path[-1].replace("-", "_").lower() in FILE_BASE64_KEYS:
|
||||||
|
# Raw base64 is treated as file content only for an explicit file field.
|
||||||
|
is_file = len(value) >= 64 and bool(re.fullmatch(r"[A-Za-z0-9+/=\s]+", value))
|
||||||
|
if not is_file:
|
||||||
|
return None
|
||||||
|
|
||||||
|
compact = "".join(payload.split())
|
||||||
|
preview = compact[:FILE_BASE64_PREVIEW_CHARS]
|
||||||
|
total_bytes = _decoded_base64_size(compact)
|
||||||
|
preview_bytes = min(total_bytes, (len(preview) * 3) // 4)
|
||||||
|
remaining_bytes = max(0, total_bytes - preview_bytes)
|
||||||
|
return f"{prefix}{preview}...<remaining_file_bytes:{remaining_bytes}>"
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_url(value: str) -> str:
|
def _sanitize_url(value: str) -> str:
|
||||||
@@ -65,10 +117,13 @@ def _sanitize_url(value: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def sanitize_log_value(value: Any) -> Any:
|
def sanitize_log_value(value: Any, *, key_path: tuple[str, ...] = ()) -> Any:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
|
file_preview = _file_base64_preview(value, key_path)
|
||||||
|
if file_preview is not None:
|
||||||
|
return file_preview
|
||||||
text = _sanitize_url(value) if value.startswith(("http://", "https://")) else value
|
text = _sanitize_url(value) if value.startswith(("http://", "https://")) else value
|
||||||
if len(text) > MAX_LOG_FIELD_LENGTH:
|
if len(text) > MAX_LOG_FIELD_LENGTH:
|
||||||
return text[:MAX_LOG_FIELD_LENGTH] + f"...<truncated:{len(text) - MAX_LOG_FIELD_LENGTH}>"
|
return text[:MAX_LOG_FIELD_LENGTH] + f"...<truncated:{len(text) - MAX_LOG_FIELD_LENGTH}>"
|
||||||
@@ -77,10 +132,14 @@ def sanitize_log_value(value: Any) -> Any:
|
|||||||
output: dict[str, Any] = {}
|
output: dict[str, Any] = {}
|
||||||
for k, v in value.items():
|
for k, v in value.items():
|
||||||
key = str(k)
|
key = str(k)
|
||||||
output[key] = "***" if _is_sensitive_key(key) else sanitize_log_value(v)
|
output[key] = (
|
||||||
|
"***"
|
||||||
|
if _is_sensitive_key(key)
|
||||||
|
else sanitize_log_value(v, key_path=(*key_path, key))
|
||||||
|
)
|
||||||
return output
|
return output
|
||||||
if isinstance(value, list):
|
if isinstance(value, (list, tuple)):
|
||||||
return [sanitize_log_value(v) for v in value]
|
return [sanitize_log_value(v, key_path=(*key_path, str(index))) for index, v in enumerate(value)]
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -262,6 +321,13 @@ def log_ai_model_event(
|
|||||||
*,
|
*,
|
||||||
event_type: str,
|
event_type: str,
|
||||||
module: str | None = None,
|
module: str | None = None,
|
||||||
|
step_code: str | None = None,
|
||||||
|
call_id: str | None = None,
|
||||||
|
event_phase: str | None = None,
|
||||||
|
owner_type: str | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
generation_attempt_no: int | None = None,
|
||||||
|
latency_ms: int | None = None,
|
||||||
event_status: str = "success",
|
event_status: str = "success",
|
||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
trace_id: str | None = None,
|
trace_id: str | None = None,
|
||||||
@@ -314,8 +380,19 @@ def log_ai_model_event(
|
|||||||
)
|
)
|
||||||
entry.update(
|
entry.update(
|
||||||
{
|
{
|
||||||
|
"call_id": call_id,
|
||||||
|
"event_phase": event_phase or event_type,
|
||||||
|
"step_code": step_code,
|
||||||
|
"owner_type": owner_type,
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"generation_attempt_no": generation_attempt_no,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
# Preserve the legacy fields for existing log readers while also
|
||||||
|
# exposing unambiguous configuration/provider model names.
|
||||||
"model_name": model_config_name,
|
"model_name": model_config_name,
|
||||||
"model_id": model_name,
|
"model_id": model_name,
|
||||||
|
"model_config_name": model_config_name,
|
||||||
|
"provider_model_name": model_name,
|
||||||
"model_config_id": model_config_id,
|
"model_config_id": model_config_id,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"api_base": api_base,
|
"api_base": api_base,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
@@ -14,10 +16,8 @@ from app.enums.private_portrait import (
|
|||||||
ARK_PRIVATE_PORTRAIT_VERSION,
|
ARK_PRIVATE_PORTRAIT_VERSION,
|
||||||
ArkPrivatePortraitAction,
|
ArkPrivatePortraitAction,
|
||||||
PrivatePortraitEventSource,
|
PrivatePortraitEventSource,
|
||||||
PrivatePortraitEventStatus,
|
|
||||||
PrivatePortraitEventType,
|
|
||||||
)
|
)
|
||||||
from app.services.operation_log_service import log_remote_api_event
|
from app.services.operation_log_service import log_ai_model_event
|
||||||
from app.services.private_portrait.rate_limiter import acquire_private_portrait_action_token
|
from app.services.private_portrait.rate_limiter import acquire_private_portrait_action_token
|
||||||
|
|
||||||
DOMAIN = "private_portrait"
|
DOMAIN = "private_portrait"
|
||||||
@@ -117,39 +117,69 @@ class ArkPrivateAssetClient:
|
|||||||
async def _call(self, action: ArkPrivatePortraitAction, payload: dict[str, Any]) -> dict[str, Any]:
|
async def _call(self, action: ArkPrivatePortraitAction, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
action_value = action.value
|
action_value = action.value
|
||||||
await acquire_private_portrait_action_token(action=action_value, wait_timeout_seconds=2.0, for_celery=self.for_celery)
|
await acquire_private_portrait_action_token(action=action_value, wait_timeout_seconds=2.0, for_celery=self.for_celery)
|
||||||
log_remote_api_event(
|
call_id = uuid.uuid4().hex
|
||||||
domain=DOMAIN,
|
source = PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value
|
||||||
|
started = time.perf_counter()
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="REQUEST",
|
||||||
|
event_phase="REQUEST",
|
||||||
|
event_status="started",
|
||||||
|
module=DOMAIN,
|
||||||
|
step_code=action_value,
|
||||||
|
call_id=call_id,
|
||||||
|
source=source,
|
||||||
remote_action=action_value,
|
remote_action=action_value,
|
||||||
event_type=PrivatePortraitEventType.ARK_API_CALL_START.value,
|
provider="volcengine_ark",
|
||||||
event_status=PrivatePortraitEventStatus.PENDING.value,
|
|
||||||
source=PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value,
|
|
||||||
request=payload,
|
request=payload,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
result = await asyncio.to_thread(self._call_sync, action, payload)
|
result = await asyncio.to_thread(self._call_sync, action, payload)
|
||||||
log_remote_api_event(
|
log_ai_model_event(
|
||||||
domain=DOMAIN,
|
event_type="RESPONSE",
|
||||||
|
event_phase="RESPONSE",
|
||||||
|
event_status="success",
|
||||||
|
module=DOMAIN,
|
||||||
|
step_code=action_value,
|
||||||
|
call_id=call_id,
|
||||||
|
source=source,
|
||||||
remote_action=action_value,
|
remote_action=action_value,
|
||||||
event_type=PrivatePortraitEventType.ARK_API_CALL_SUCCESS.value,
|
|
||||||
event_status=PrivatePortraitEventStatus.SUCCESS.value,
|
|
||||||
source=PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value,
|
|
||||||
request=payload,
|
|
||||||
response=result,
|
|
||||||
remote_request_id=result.get("RequestId") or result.get("request_id"),
|
remote_request_id=result.get("RequestId") or result.get("request_id"),
|
||||||
|
provider="volcengine_ark",
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
response=result,
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except ArkPrivateAssetRemoteError as exc:
|
except ArkPrivateAssetRemoteError as exc:
|
||||||
log_remote_api_event(
|
log_ai_model_event(
|
||||||
domain=DOMAIN,
|
event_type="RESPONSE",
|
||||||
|
event_phase="RESPONSE",
|
||||||
|
event_status="failed",
|
||||||
|
module=DOMAIN,
|
||||||
|
step_code=action_value,
|
||||||
|
call_id=call_id,
|
||||||
|
source=source,
|
||||||
remote_action=action_value,
|
remote_action=action_value,
|
||||||
event_type=PrivatePortraitEventType.ARK_API_CALL_FAILED.value,
|
|
||||||
event_status=PrivatePortraitEventStatus.FAILED.value,
|
|
||||||
source=PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value,
|
|
||||||
request=payload,
|
|
||||||
response=exc.raw,
|
|
||||||
remote_request_id=exc.request_id,
|
remote_request_id=exc.request_id,
|
||||||
remote_code=exc.code,
|
provider="volcengine_ark",
|
||||||
remote_message=exc.message,
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
response=exc.raw,
|
||||||
|
detail={"remote_code": exc.code, "remote_message": exc.message},
|
||||||
|
error=exc.message,
|
||||||
|
)
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="ERROR",
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status="failed",
|
||||||
|
module=DOMAIN,
|
||||||
|
step_code=action_value,
|
||||||
|
call_id=call_id,
|
||||||
|
source=source,
|
||||||
|
remote_action=action_value,
|
||||||
|
remote_request_id=exc.request_id,
|
||||||
|
provider="volcengine_ark",
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
detail={"remote_code": exc.code, "remote_message": exc.message},
|
||||||
|
error=str(exc),
|
||||||
)
|
)
|
||||||
if self.for_celery:
|
if self.for_celery:
|
||||||
raise
|
raise
|
||||||
@@ -157,14 +187,18 @@ class ArkPrivateAssetClient:
|
|||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log_remote_api_event(
|
log_ai_model_event(
|
||||||
domain=DOMAIN,
|
event_type="ERROR",
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status="failed",
|
||||||
|
module=DOMAIN,
|
||||||
|
step_code=action_value,
|
||||||
|
call_id=call_id,
|
||||||
|
source=source,
|
||||||
remote_action=action_value,
|
remote_action=action_value,
|
||||||
event_type=PrivatePortraitEventType.ARK_API_CALL_FAILED.value,
|
provider="volcengine_ark",
|
||||||
event_status=PrivatePortraitEventStatus.FAILED.value,
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
source=PrivatePortraitEventSource.CELERY.value if self.for_celery else PrivatePortraitEventSource.SERVICE.value,
|
error=str(exc),
|
||||||
request=payload,
|
|
||||||
remote_message=str(exc),
|
|
||||||
)
|
)
|
||||||
if self.for_celery:
|
if self.for_celery:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -921,6 +921,13 @@ async def run_image_prompt_optimize(
|
|||||||
user_id=user_id_value,
|
user_id=user_id_value,
|
||||||
references=references,
|
references=references,
|
||||||
gen_type="image",
|
gen_type="image",
|
||||||
|
log_module=module_value,
|
||||||
|
log_step="shot_replicate_image_prompt_optimize",
|
||||||
|
log_project_id=project_id_value,
|
||||||
|
log_task_id=step_id_value,
|
||||||
|
log_owner_type="module_generation_step",
|
||||||
|
log_owner_id=step_id_value,
|
||||||
|
generation_attempt_no=expected_step_version,
|
||||||
)
|
)
|
||||||
if execution_guard is not None:
|
if execution_guard is not None:
|
||||||
await execution_guard()
|
await execution_guard()
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ def _log_shot_ai_model_event(
|
|||||||
event_status: str,
|
event_status: str,
|
||||||
config: ModelConfig,
|
config: ModelConfig,
|
||||||
trace_id: str,
|
trace_id: str,
|
||||||
|
call_id: str,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
task_set_id: str | None,
|
task_set_id: str | None,
|
||||||
segment_id: str | None,
|
segment_id: str | None,
|
||||||
@@ -487,29 +488,60 @@ def _log_shot_ai_model_event(
|
|||||||
"remote_message": remote_message,
|
"remote_message": remote_message,
|
||||||
"remote_param": remote_param,
|
"remote_param": remote_param,
|
||||||
})
|
})
|
||||||
|
common = {
|
||||||
|
"source": LogSourceEnum.REMOTE_API.value,
|
||||||
|
"module": ModuleCodeEnum.SHOT_REPLICATE.value,
|
||||||
|
"step_code": "source_video_analysis" if mode == "full_breakdown" else "segment_video_analysis",
|
||||||
|
"call_id": call_id,
|
||||||
|
"trace_id": trace_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"project_id": task_set_id,
|
||||||
|
"task_id": segment_id or task_set_id,
|
||||||
|
"step_id": segment_id,
|
||||||
|
"owner_type": "shot_replicate_segment" if segment_id else "shot_replicate_task_set",
|
||||||
|
"owner_id": segment_id or task_set_id,
|
||||||
|
"remote_action": action,
|
||||||
|
"remote_request_id": remote_request_id,
|
||||||
|
"model_config_id": str(config.id),
|
||||||
|
"model_config_name": config.name,
|
||||||
|
"model_name": config.model_name,
|
||||||
|
"provider": config.provider,
|
||||||
|
"api_base": config.api_base,
|
||||||
|
"http_status": http_status,
|
||||||
|
}
|
||||||
|
normalized_status = str(event_status or "").lower()
|
||||||
|
if normalized_status == str(LogEventStatusEnum.STARTED.value).lower():
|
||||||
log_ai_model_event(
|
log_ai_model_event(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
|
event_phase="REQUEST",
|
||||||
event_status=event_status,
|
event_status=event_status,
|
||||||
source=LogSourceEnum.REMOTE_API.value,
|
|
||||||
module=ModuleCodeEnum.SHOT_REPLICATE.value,
|
|
||||||
trace_id=trace_id,
|
|
||||||
user_id=user_id,
|
|
||||||
project_id=task_set_id,
|
|
||||||
step_id=segment_id,
|
|
||||||
remote_action=action,
|
|
||||||
remote_request_id=remote_request_id,
|
|
||||||
model_config_id=str(config.id),
|
|
||||||
model_config_name=config.name,
|
|
||||||
model_name=config.model_name,
|
|
||||||
provider=config.provider,
|
|
||||||
api_base=config.api_base,
|
|
||||||
http_status=http_status,
|
|
||||||
request=request_data,
|
request=request_data,
|
||||||
|
message=message,
|
||||||
|
detail=detail,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if response_data is not None:
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type=event_type,
|
||||||
|
event_phase="RESPONSE",
|
||||||
|
event_status=event_status,
|
||||||
response=response_data,
|
response=response_data,
|
||||||
token_usage=token_usage,
|
token_usage=token_usage,
|
||||||
message=message,
|
message=message,
|
||||||
detail=detail,
|
detail=detail,
|
||||||
error=error,
|
error=error if normalized_status == str(LogEventStatusEnum.FAILED.value).lower() else None,
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
if normalized_status == str(LogEventStatusEnum.FAILED.value).lower() or error:
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type=event_type,
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status=LogEventStatusEnum.FAILED.value,
|
||||||
|
message=message,
|
||||||
|
detail=detail,
|
||||||
|
error=error or remote_message or "AI model call failed",
|
||||||
|
**common,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def analyze_video_for_shot_split(
|
async def analyze_video_for_shot_split(
|
||||||
@@ -529,6 +561,7 @@ async def analyze_video_for_shot_split(
|
|||||||
也不再 fallback 到 SEEDANCE_*,避免拆镜分析走错通道。
|
也不再 fallback 到 SEEDANCE_*,避免拆镜分析走错通道。
|
||||||
"""
|
"""
|
||||||
trace_id = trace_id or generate_id()
|
trace_id = trace_id or generate_id()
|
||||||
|
call_id = generate_id()
|
||||||
config_row = await _select_model_config(db)
|
config_row = await _select_model_config(db)
|
||||||
if not config_row:
|
if not config_row:
|
||||||
raise RuntimeError("拆镜分析模型未配置:请先在 model_configs 表启用可用模型")
|
raise RuntimeError("拆镜分析模型未配置:请先在 model_configs 表启用可用模型")
|
||||||
@@ -585,6 +618,7 @@ async def analyze_video_for_shot_split(
|
|||||||
await db.rollback()
|
await db.rollback()
|
||||||
|
|
||||||
_log_shot_ai_model_event(
|
_log_shot_ai_model_event(
|
||||||
|
call_id=call_id,
|
||||||
event_type=(
|
event_type=(
|
||||||
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_STARTED.value
|
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_STARTED.value
|
||||||
if mode == "full_breakdown"
|
if mode == "full_breakdown"
|
||||||
@@ -610,6 +644,7 @@ async def analyze_video_for_shot_split(
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log_shot_ai_model_event(
|
_log_shot_ai_model_event(
|
||||||
|
call_id=call_id,
|
||||||
event_type=(
|
event_type=(
|
||||||
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_FAILED.value
|
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_FAILED.value
|
||||||
if mode == "full_breakdown"
|
if mode == "full_breakdown"
|
||||||
@@ -633,6 +668,7 @@ async def analyze_video_for_shot_split(
|
|||||||
remote_request_id, remote_code, remote_message, remote_param = _extract_remote_error(response_data)
|
remote_request_id, remote_code, remote_message, remote_param = _extract_remote_error(response_data)
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
_log_shot_ai_model_event(
|
_log_shot_ai_model_event(
|
||||||
|
call_id=call_id,
|
||||||
event_type=(
|
event_type=(
|
||||||
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_FAILED.value
|
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_FAILED.value
|
||||||
if mode == "full_breakdown"
|
if mode == "full_breakdown"
|
||||||
@@ -661,6 +697,7 @@ async def analyze_video_for_shot_split(
|
|||||||
raw = response.json()
|
raw = response.json()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log_shot_ai_model_event(
|
_log_shot_ai_model_event(
|
||||||
|
call_id=call_id,
|
||||||
event_type=ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value,
|
event_type=ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value,
|
||||||
event_status=LogEventStatusEnum.FAILED.value,
|
event_status=LogEventStatusEnum.FAILED.value,
|
||||||
config=config,
|
config=config,
|
||||||
@@ -683,6 +720,7 @@ async def analyze_video_for_shot_split(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
event_type = ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value if "content 为空" in str(exc) else ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value
|
event_type = ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_EMPTY.value if "content 为空" in str(exc) else ShotReplicateLogEventEnum.ANALYSIS_RESPONSE_PARSE_FAILED.value
|
||||||
_log_shot_ai_model_event(
|
_log_shot_ai_model_event(
|
||||||
|
call_id=call_id,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
event_status=LogEventStatusEnum.FAILED.value,
|
event_status=LogEventStatusEnum.FAILED.value,
|
||||||
config=config,
|
config=config,
|
||||||
@@ -718,7 +756,6 @@ async def analyze_video_for_shot_split(
|
|||||||
"split_max_seconds": _split_max_seconds(),
|
"split_max_seconds": _split_max_seconds(),
|
||||||
"analysis_mode": mode,
|
"analysis_mode": mode,
|
||||||
"trace_id": trace_id,
|
"trace_id": trace_id,
|
||||||
"log_request": log_request_data,
|
|
||||||
}
|
}
|
||||||
if not token_usage["total_tokens"]:
|
if not token_usage["total_tokens"]:
|
||||||
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
|
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
|
||||||
@@ -731,6 +768,7 @@ async def analyze_video_for_shot_split(
|
|||||||
})
|
})
|
||||||
|
|
||||||
_log_shot_ai_model_event(
|
_log_shot_ai_model_event(
|
||||||
|
call_id=call_id,
|
||||||
event_type=(
|
event_type=(
|
||||||
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_SUCCESS.value
|
ShotReplicateLogEventEnum.ANALYSIS_REMOTE_API_SUCCESS.value
|
||||||
if mode == "full_breakdown"
|
if mode == "full_breakdown"
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -13,7 +11,8 @@ from volcenginesdkarkruntime import AsyncArk
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX
|
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX
|
||||||
from app.models.video_engine import VideoEngine
|
from app.models.video_engine import VideoEngine
|
||||||
from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
|
from app.services.operation_log_service import build_exception_detail, log_ai_model_event
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
from app.types.generation.provider import (
|
from app.types.generation.provider import (
|
||||||
ProviderGenerationRecordLike,
|
ProviderGenerationRecordLike,
|
||||||
ProviderVideoEngineLike,
|
ProviderVideoEngineLike,
|
||||||
@@ -22,55 +21,27 @@ from app.types.generation.provider import (
|
|||||||
logger = logging.getLogger("videogen")
|
logger = logging.getLogger("videogen")
|
||||||
|
|
||||||
|
|
||||||
def _log_video_request(engine: ProviderVideoEngineLike, record_id: str, request_data: dict):
|
|
||||||
"""Log video generation request to log/AiModel/YYYY-MM-DD.log"""
|
def _provider_log_context(engine, record, *, call_id: str, step_code: str) -> dict:
|
||||||
if not is_enabled():
|
generation_mode = str(getattr(record, "generation_mode", "") or "generation_record")
|
||||||
return
|
owner_type = "chat_generation_task" if generation_mode != "generation_record" else "generation_record"
|
||||||
try:
|
return {
|
||||||
os.makedirs(LOG_DIR, exist_ok=True)
|
"module": generation_mode,
|
||||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
"step_code": step_code,
|
||||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
"call_id": call_id,
|
||||||
request_str = json.dumps(request_data, ensure_ascii=False)
|
"source": "app.services.video_gen",
|
||||||
request_encrypted = encrypt_data(request_data, True)
|
"user_id": str(getattr(record, "user_id", "") or "") or None,
|
||||||
entry = {
|
"project_id": str(getattr(record, "project_id", "") or "") or None,
|
||||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"task_id": str(getattr(record, "id", "") or "") or None,
|
||||||
"type": "video_gen_request",
|
"owner_type": owner_type,
|
||||||
"engine": engine.name,
|
"owner_id": str(getattr(record, "id", "") or "") or None,
|
||||||
"model": engine.model_name,
|
"generation_attempt_no": int(getattr(record, "generation_attempt_no", 1) or 1),
|
||||||
"record_id": record_id,
|
"model_config_id": str(getattr(engine, "id", "") or "") or None,
|
||||||
"request": request_encrypted,
|
"model_config_name": str(getattr(engine, "name", "") or "") or None,
|
||||||
"request_length": len(request_str),
|
"model_name": str(getattr(engine, "model_name", "") or "") or None,
|
||||||
|
"provider": str(getattr(engine, "provider", "") or "") or None,
|
||||||
|
"api_base": str(getattr(engine, "api_base", "") or "") or None,
|
||||||
}
|
}
|
||||||
with open(log_file, "a", encoding="utf-8") as f:
|
|
||||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _log_video_response(record_id: str, response_data: dict, error: str | None = None):
|
|
||||||
"""Log video generation response to log/AiModel/YYYY-MM-DD.log"""
|
|
||||||
if not is_enabled():
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
os.makedirs(LOG_DIR, exist_ok=True)
|
|
||||||
today = datetime.now().strftime(LOG_DATE_FORMAT)
|
|
||||||
log_file = os.path.join(LOG_DIR, f"{today}.log")
|
|
||||||
response_encrypted = encrypt_data(response_data, True) if response_data else ""
|
|
||||||
|
|
||||||
entry = {
|
|
||||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
||||||
"type": "video_gen_response",
|
|
||||||
"record_id": record_id,
|
|
||||||
"response": response_encrypted,
|
|
||||||
"error": error,
|
|
||||||
}
|
|
||||||
with open(log_file, "a", encoding="utf-8") as f:
|
|
||||||
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def get_active_engine(db: AsyncSession) -> VideoEngine:
|
async def get_active_engine(db: AsyncSession) -> VideoEngine:
|
||||||
@@ -153,19 +124,47 @@ async def submit_video_task(
|
|||||||
"watermark": False,
|
"watermark": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Log request to AiModel log. include_media_references 只用于排查日志,不传给供应商 API。
|
call_id = generate_id()
|
||||||
_log_video_request(
|
started = time.perf_counter()
|
||||||
|
log_context = _provider_log_context(
|
||||||
engine,
|
engine,
|
||||||
record.id,
|
record,
|
||||||
{**request_payload, "include_media_references": include_media_references},
|
call_id=call_id,
|
||||||
|
step_code="video_create",
|
||||||
|
)
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="REQUEST",
|
||||||
|
event_phase="REQUEST",
|
||||||
|
event_status="started",
|
||||||
|
remote_action="video_create",
|
||||||
|
request={**request_payload, "include_media_references": include_media_references},
|
||||||
|
**log_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await client.content_generation.tasks.create(**request_payload)
|
result = await client.content_generation.tasks.create(**request_payload)
|
||||||
task_id = result.id
|
task_id = result.id
|
||||||
_log_video_response(record.id, {"task_id": task_id})
|
log_ai_model_event(
|
||||||
except Exception as e:
|
event_type="RESPONSE",
|
||||||
_log_video_response(record.id, {}, str(e))
|
event_phase="RESPONSE",
|
||||||
|
event_status="success",
|
||||||
|
remote_action="video_create",
|
||||||
|
remote_request_id=task_id,
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
response={"task_id": task_id},
|
||||||
|
**log_context,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="ERROR",
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status="failed",
|
||||||
|
remote_action="video_create",
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
detail=build_exception_detail(exc),
|
||||||
|
error=str(exc),
|
||||||
|
**log_context,
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import os
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlsplit, urlunsplit
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
@@ -305,6 +306,28 @@ async def enqueue_upscale_task(db: AsyncSession, *, upscale: VideoUpscaleTask, r
|
|||||||
upscale.status = VideoUpscaleTaskStatus.PENDING.value
|
upscale.status = VideoUpscaleTaskStatus.PENDING.value
|
||||||
upscale.stage = VideoUpscaleStage.QUEUED.value
|
upscale.stage = VideoUpscaleStage.QUEUED.value
|
||||||
upscale.next_retry_at = None
|
upscale.next_retry_at = None
|
||||||
|
log_snapshot = SimpleNamespace(
|
||||||
|
id=upscale_id,
|
||||||
|
chat_generation_task_id=(
|
||||||
|
str(upscale.chat_generation_task_id)
|
||||||
|
if upscale.chat_generation_task_id
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
generation_record_id=(
|
||||||
|
str(upscale.generation_record_id)
|
||||||
|
if upscale.generation_record_id
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
processor_key=processor_key,
|
||||||
|
status=VideoUpscaleTaskStatus.PENDING.value,
|
||||||
|
stage=VideoUpscaleStage.QUEUED.value,
|
||||||
|
attempt_count=int(upscale.attempt_count or 0),
|
||||||
|
failure_count=int(upscale.failure_count or 0),
|
||||||
|
provider_task_id=(str(upscale.provider_task_id) if upscale.provider_task_id else None),
|
||||||
|
input_source_type=upscale.input_source_type,
|
||||||
|
target_width=upscale.target_width,
|
||||||
|
target_height=upscale.target_height,
|
||||||
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -324,7 +347,7 @@ async def enqueue_upscale_task(db: AsyncSession, *, upscale: VideoUpscaleTask, r
|
|||||||
raise RuntimeError(f"未注册的超分处理器: {processor_key}")
|
raise RuntimeError(f"未注册的超分处理器: {processor_key}")
|
||||||
log_video_upscale_event(
|
log_video_upscale_event(
|
||||||
event_type="upscale_task_enqueued",
|
event_type="upscale_task_enqueued",
|
||||||
upscale_task=upscale,
|
upscale_task=log_snapshot,
|
||||||
detail={"reason": reason, "celery_task_id": celery_id, "processor_key": processor_key},
|
detail={"reason": reason, "celery_task_id": celery_id, "processor_key": processor_key},
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -333,7 +356,7 @@ async def enqueue_upscale_task(db: AsyncSession, *, upscale: VideoUpscaleTask, r
|
|||||||
log_video_upscale_event(
|
log_video_upscale_event(
|
||||||
event_type="upscale_task_enqueue_failed",
|
event_type="upscale_task_enqueue_failed",
|
||||||
event_status="failed",
|
event_status="failed",
|
||||||
upscale_task=upscale,
|
upscale_task=log_snapshot,
|
||||||
message=str(exc),
|
message=str(exc),
|
||||||
detail={"reason": reason, "celery_task_id": celery_id, "processor_key": processor_key},
|
detail={"reason": reason, "celery_task_id": celery_id, "processor_key": processor_key},
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.enums.video_upscale import VideoUpscaleProcessorKey
|
from app.enums.video_upscale import VideoUpscaleProcessorKey
|
||||||
|
from app.services.operation_log_service import log_ai_model_event
|
||||||
|
|
||||||
|
|
||||||
class VolcMediaKitError(RuntimeError):
|
class VolcMediaKitError(RuntimeError):
|
||||||
@@ -171,7 +174,24 @@ async def submit_video_enhance(
|
|||||||
processor=processor,
|
processor=processor,
|
||||||
client_token=client_token,
|
client_token=client_token,
|
||||||
)
|
)
|
||||||
|
call_id = uuid.uuid4().hex
|
||||||
|
started = time.perf_counter()
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="REQUEST",
|
||||||
|
event_phase="REQUEST",
|
||||||
|
event_status="started",
|
||||||
|
module="video_upscale",
|
||||||
|
step_code="provider_submit",
|
||||||
|
call_id=call_id,
|
||||||
|
source="app.services.video_upscale.volc_service",
|
||||||
|
remote_action=endpoint,
|
||||||
|
provider="volcengine_mediakit",
|
||||||
|
api_base=_base_url(),
|
||||||
|
request=payload,
|
||||||
|
)
|
||||||
timeout = max(3, int(processor.get("request_timeout_seconds") or settings.VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS))
|
timeout = max(3, int(processor.get("request_timeout_seconds") or settings.VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS))
|
||||||
|
response: httpx.Response | None = None
|
||||||
|
data: dict[str, Any] = {}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||||
response = await client.post(f"{_base_url()}{endpoint}", headers=_headers(), json=payload)
|
response = await client.post(f"{_base_url()}{endpoint}", headers=_headers(), json=payload)
|
||||||
@@ -179,6 +199,23 @@ async def submit_video_enhance(
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
data = {"success": False, "error": {"message": response.text[:2000]}}
|
data = {"success": False, "error": {"message": response.text[:2000]}}
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="RESPONSE",
|
||||||
|
event_phase="RESPONSE",
|
||||||
|
event_status="success" if response.status_code < 400 else "failed",
|
||||||
|
module="video_upscale",
|
||||||
|
step_code="provider_submit",
|
||||||
|
call_id=call_id,
|
||||||
|
source="app.services.video_upscale.volc_service",
|
||||||
|
remote_action=endpoint,
|
||||||
|
remote_request_id=str(data.get("request_id") or "") or None,
|
||||||
|
provider="volcengine_mediakit",
|
||||||
|
api_base=_base_url(),
|
||||||
|
http_status=response.status_code,
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
response=data,
|
||||||
|
error=None if response.status_code < 400 else str((data.get("error") or {}).get("message") or "remote error"),
|
||||||
|
)
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
raise _error_from_payload(
|
raise _error_from_payload(
|
||||||
data,
|
data,
|
||||||
@@ -186,18 +223,51 @@ async def submit_video_enhance(
|
|||||||
http_status=response.status_code,
|
http_status=response.status_code,
|
||||||
endpoint=endpoint,
|
endpoint=endpoint,
|
||||||
)
|
)
|
||||||
except VolcMediaKitError:
|
if not bool(data.get("success")) or not data.get("task_id"):
|
||||||
|
raise _error_from_payload(data, "火山超分提交失败", endpoint=endpoint)
|
||||||
|
except VolcMediaKitError as exc:
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="ERROR",
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status="failed",
|
||||||
|
module="video_upscale",
|
||||||
|
step_code="provider_submit",
|
||||||
|
call_id=call_id,
|
||||||
|
source="app.services.video_upscale.volc_service",
|
||||||
|
remote_action=endpoint,
|
||||||
|
remote_request_id=exc.request_id,
|
||||||
|
provider="volcengine_mediakit",
|
||||||
|
api_base=_base_url(),
|
||||||
|
http_status=exc.http_status or (response.status_code if response is not None else None),
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
detail=exc.log_detail(),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||||
raise VolcMediaKitError(
|
wrapped = VolcMediaKitError(
|
||||||
f"火山超分提交网络异常: {exc}",
|
f"火山超分提交网络异常: {exc}",
|
||||||
code="NetworkError",
|
code="NetworkError",
|
||||||
retryable=True,
|
retryable=True,
|
||||||
endpoint=endpoint,
|
endpoint=endpoint,
|
||||||
) from exc
|
)
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="ERROR",
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status="failed",
|
||||||
|
module="video_upscale",
|
||||||
|
step_code="provider_submit",
|
||||||
|
call_id=call_id,
|
||||||
|
source="app.services.video_upscale.volc_service",
|
||||||
|
remote_action=endpoint,
|
||||||
|
provider="volcengine_mediakit",
|
||||||
|
api_base=_base_url(),
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
detail=wrapped.log_detail(),
|
||||||
|
error=str(wrapped),
|
||||||
|
)
|
||||||
|
raise wrapped from exc
|
||||||
|
|
||||||
if not bool(data.get("success")) or not data.get("task_id"):
|
|
||||||
raise _error_from_payload(data, "火山超分提交失败", endpoint=endpoint)
|
|
||||||
return VolcSubmitResult(
|
return VolcSubmitResult(
|
||||||
task_id=str(data["task_id"]),
|
task_id=str(data["task_id"]),
|
||||||
request_id=str(data.get("request_id")) if data.get("request_id") else None,
|
request_id=str(data.get("request_id")) if data.get("request_id") else None,
|
||||||
@@ -209,7 +279,26 @@ async def submit_video_enhance(
|
|||||||
|
|
||||||
async def query_task(task_id: str, *, request_timeout_seconds: int | None = None) -> VolcQueryResult:
|
async def query_task(task_id: str, *, request_timeout_seconds: int | None = None) -> VolcQueryResult:
|
||||||
endpoint = f"/api/v1/tasks/{task_id}"
|
endpoint = f"/api/v1/tasks/{task_id}"
|
||||||
|
call_id = uuid.uuid4().hex
|
||||||
|
started = time.perf_counter()
|
||||||
|
request_payload = {"task_id": task_id}
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="REQUEST",
|
||||||
|
event_phase="REQUEST",
|
||||||
|
event_status="started",
|
||||||
|
module="video_upscale",
|
||||||
|
step_code="provider_poll",
|
||||||
|
call_id=call_id,
|
||||||
|
source="app.services.video_upscale.volc_service",
|
||||||
|
remote_action=endpoint,
|
||||||
|
remote_request_id=task_id,
|
||||||
|
provider="volcengine_mediakit",
|
||||||
|
api_base=_base_url(),
|
||||||
|
request=request_payload,
|
||||||
|
)
|
||||||
timeout = max(3, int(request_timeout_seconds or settings.VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS))
|
timeout = max(3, int(request_timeout_seconds or settings.VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS))
|
||||||
|
response: httpx.Response | None = None
|
||||||
|
data: dict[str, Any] = {}
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||||
response = await client.get(f"{_base_url()}{endpoint}", headers=_headers())
|
response = await client.get(f"{_base_url()}{endpoint}", headers=_headers())
|
||||||
@@ -217,6 +306,23 @@ async def query_task(task_id: str, *, request_timeout_seconds: int | None = None
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
data = {"success": False, "error": {"message": response.text[:2000]}}
|
data = {"success": False, "error": {"message": response.text[:2000]}}
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="RESPONSE",
|
||||||
|
event_phase="RESPONSE",
|
||||||
|
event_status="success" if response.status_code < 400 else "failed",
|
||||||
|
module="video_upscale",
|
||||||
|
step_code="provider_poll",
|
||||||
|
call_id=call_id,
|
||||||
|
source="app.services.video_upscale.volc_service",
|
||||||
|
remote_action=endpoint,
|
||||||
|
remote_request_id=str(data.get("request_id") or task_id),
|
||||||
|
provider="volcengine_mediakit",
|
||||||
|
api_base=_base_url(),
|
||||||
|
http_status=response.status_code,
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
response=data,
|
||||||
|
error=None if response.status_code < 400 else str((data.get("error") or {}).get("message") or "remote error"),
|
||||||
|
)
|
||||||
if response.status_code >= 400:
|
if response.status_code >= 400:
|
||||||
raise _error_from_payload(
|
raise _error_from_payload(
|
||||||
data,
|
data,
|
||||||
@@ -224,16 +330,6 @@ async def query_task(task_id: str, *, request_timeout_seconds: int | None = None
|
|||||||
http_status=response.status_code,
|
http_status=response.status_code,
|
||||||
endpoint=endpoint,
|
endpoint=endpoint,
|
||||||
)
|
)
|
||||||
except VolcMediaKitError:
|
|
||||||
raise
|
|
||||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
|
||||||
raise VolcMediaKitError(
|
|
||||||
f"火山超分查询网络异常: {exc}",
|
|
||||||
code="NetworkError",
|
|
||||||
retryable=True,
|
|
||||||
endpoint=endpoint,
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
if not bool(data.get("success")):
|
if not bool(data.get("success")):
|
||||||
raise _error_from_payload(data, "火山超分任务查询失败", endpoint=endpoint)
|
raise _error_from_payload(data, "火山超分任务查询失败", endpoint=endpoint)
|
||||||
status = str(data.get("status") or "").strip().lower()
|
status = str(data.get("status") or "").strip().lower()
|
||||||
@@ -246,6 +342,50 @@ async def query_task(task_id: str, *, request_timeout_seconds: int | None = None
|
|||||||
endpoint=endpoint,
|
endpoint=endpoint,
|
||||||
response_payload=data,
|
response_payload=data,
|
||||||
)
|
)
|
||||||
|
except VolcMediaKitError as exc:
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="ERROR",
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status="failed",
|
||||||
|
module="video_upscale",
|
||||||
|
step_code="provider_poll",
|
||||||
|
call_id=call_id,
|
||||||
|
source="app.services.video_upscale.volc_service",
|
||||||
|
remote_action=endpoint,
|
||||||
|
remote_request_id=exc.request_id or task_id,
|
||||||
|
provider="volcengine_mediakit",
|
||||||
|
api_base=_base_url(),
|
||||||
|
http_status=exc.http_status or (response.status_code if response is not None else None),
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
detail=exc.log_detail(),
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||||
|
wrapped = VolcMediaKitError(
|
||||||
|
f"火山超分查询网络异常: {exc}",
|
||||||
|
code="NetworkError",
|
||||||
|
retryable=True,
|
||||||
|
endpoint=endpoint,
|
||||||
|
)
|
||||||
|
log_ai_model_event(
|
||||||
|
event_type="ERROR",
|
||||||
|
event_phase="ERROR",
|
||||||
|
event_status="failed",
|
||||||
|
module="video_upscale",
|
||||||
|
step_code="provider_poll",
|
||||||
|
call_id=call_id,
|
||||||
|
source="app.services.video_upscale.volc_service",
|
||||||
|
remote_action=endpoint,
|
||||||
|
remote_request_id=task_id,
|
||||||
|
provider="volcengine_mediakit",
|
||||||
|
api_base=_base_url(),
|
||||||
|
latency_ms=int((time.perf_counter() - started) * 1000),
|
||||||
|
detail=wrapped.log_detail(),
|
||||||
|
error=str(wrapped),
|
||||||
|
)
|
||||||
|
raise wrapped from exc
|
||||||
|
|
||||||
expires_raw = data.get("expires_at")
|
expires_raw = data.get("expires_at")
|
||||||
try:
|
try:
|
||||||
expires_at = int(expires_raw) if expires_raw is not None else None
|
expires_at = int(expires_raw) if expires_raw is not None else None
|
||||||
|
|||||||
@@ -213,6 +213,24 @@ async def _remove_active(owner: GenerationOwner) -> None:
|
|||||||
await remove_download_active(_registry_id(owner))
|
await remove_download_active(_registry_id(owner))
|
||||||
|
|
||||||
|
|
||||||
|
async def _reload_owner_after_commit(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
owner_type: str,
|
||||||
|
owner_id: str,
|
||||||
|
attempt_no: int,
|
||||||
|
) -> GenerationOwner | None:
|
||||||
|
owner = await load_generation_owner(
|
||||||
|
db,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
for_update=False,
|
||||||
|
)
|
||||||
|
if owner is None or not is_attempt_current(owner, attempt_no):
|
||||||
|
return None
|
||||||
|
return owner
|
||||||
|
|
||||||
|
|
||||||
async def _apply(
|
async def _apply(
|
||||||
owner: GenerationOwner,
|
owner: GenerationOwner,
|
||||||
*,
|
*,
|
||||||
@@ -297,7 +315,19 @@ async def enqueue_download_task(
|
|||||||
if not owner.download_storage_date_dir:
|
if not owner.download_storage_date_dir:
|
||||||
created_at = ensure_aware_utc(owner.created_at) or _now()
|
created_at = ensure_aware_utc(owner.created_at) or _now()
|
||||||
owner.download_storage_date_dir = created_at.strftime("%Y/%m/%d")
|
owner.download_storage_date_dir = created_at.strftime("%Y/%m/%d")
|
||||||
|
owner_type_snapshot = owner_type_of(owner)
|
||||||
|
owner_id_snapshot = str(owner.id)
|
||||||
|
attempt_snapshot = int(owner.generation_attempt_no or 1)
|
||||||
|
celery_task_id_snapshot = str(owner.download_celery_task_id or "") or None
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
owner = await load_generation_owner(
|
||||||
|
db,
|
||||||
|
owner_type=owner_type_snapshot,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
for_update=False,
|
||||||
|
)
|
||||||
|
if owner is None or not is_attempt_current(owner, attempt_snapshot):
|
||||||
|
return None
|
||||||
|
|
||||||
priority = int(
|
priority = int(
|
||||||
settings.DOWNLOAD_TASK_PRIORITY_RECOVER
|
settings.DOWNLOAD_TASK_PRIORITY_RECOVER
|
||||||
@@ -325,7 +355,7 @@ async def enqueue_download_task(
|
|||||||
detail={"reason": reason, "error": str(exc)},
|
detail={"reason": reason, "error": str(exc)},
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
return owner.download_celery_task_id
|
return str(owner.download_celery_task_id or celery_task_id_snapshot or "") or None
|
||||||
|
|
||||||
|
|
||||||
async def _claim(
|
async def _claim(
|
||||||
@@ -333,9 +363,9 @@ async def _claim(
|
|||||||
owner: GenerationOwner,
|
owner: GenerationOwner,
|
||||||
*,
|
*,
|
||||||
claim_token: str,
|
claim_token: str,
|
||||||
) -> bool:
|
) -> GenerationOwner | None:
|
||||||
if not owner_is_generating(owner) or owner_is_completed(owner):
|
if not owner_is_generating(owner) or owner_is_completed(owner):
|
||||||
return False
|
return None
|
||||||
allowed = {
|
allowed = {
|
||||||
_stage(owner, ChatGenerationPipelineStage.RESULT_READY),
|
_stage(owner, ChatGenerationPipelineStage.RESULT_READY),
|
||||||
_stage(owner, ChatGenerationPipelineStage.DOWNLOAD_QUEUED),
|
_stage(owner, ChatGenerationPipelineStage.DOWNLOAD_QUEUED),
|
||||||
@@ -343,7 +373,7 @@ async def _claim(
|
|||||||
_stage(owner, ChatGenerationPipelineStage.RETRY_WAITING),
|
_stage(owner, ChatGenerationPipelineStage.RETRY_WAITING),
|
||||||
}
|
}
|
||||||
if owner.pipeline_stage not in allowed:
|
if owner.pipeline_stage not in allowed:
|
||||||
return False
|
return None
|
||||||
|
|
||||||
now = _now()
|
now = _now()
|
||||||
# Redis execution lock is authoritative. A database lease left by a
|
# Redis execution lock is authoritative. A database lease left by a
|
||||||
@@ -356,7 +386,7 @@ async def _claim(
|
|||||||
and next_retry
|
and next_retry
|
||||||
and next_retry > now
|
and next_retry > now
|
||||||
):
|
):
|
||||||
return False
|
return None
|
||||||
|
|
||||||
owner.pipeline_stage = _stage(
|
owner.pipeline_stage = _stage(
|
||||||
owner, ChatGenerationPipelineStage.DOWNLOADING
|
owner, ChatGenerationPipelineStage.DOWNLOADING
|
||||||
@@ -366,7 +396,18 @@ async def _claim(
|
|||||||
owner.download_lease_until = _lease_until()
|
owner.download_lease_until = _lease_until()
|
||||||
owner.download_attempt_count = int(owner.download_attempt_count or 0) + 1
|
owner.download_attempt_count = int(owner.download_attempt_count or 0) + 1
|
||||||
owner.download_last_error = None
|
owner.download_last_error = None
|
||||||
|
owner_type_snapshot = owner_type_of(owner)
|
||||||
|
owner_id_snapshot = str(owner.id)
|
||||||
|
attempt_snapshot = int(owner.generation_attempt_no or 1)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
owner = await _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
owner_type=owner_type_snapshot,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
attempt_no=attempt_snapshot,
|
||||||
|
)
|
||||||
|
if owner is None:
|
||||||
|
return None
|
||||||
await _register_active(
|
await _register_active(
|
||||||
owner,
|
owner,
|
||||||
check_at=owner.download_lease_until,
|
check_at=owner.download_lease_until,
|
||||||
@@ -382,7 +423,7 @@ async def _claim(
|
|||||||
"claim_token_suffix": claim_token[-8:],
|
"claim_token_suffix": claim_token[-8:],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return True
|
return owner
|
||||||
|
|
||||||
|
|
||||||
async def _sync_snapshot(db: AsyncSession, owner: GenerationOwner) -> None:
|
async def _sync_snapshot(db: AsyncSession, owner: GenerationOwner) -> None:
|
||||||
@@ -446,12 +487,33 @@ async def _mark_failed(
|
|||||||
owner.download_last_error = error_message
|
owner.download_last_error = error_message
|
||||||
owner.download_lease_until = None
|
owner.download_lease_until = None
|
||||||
owner.download_next_retry_at = None
|
owner.download_next_retry_at = None
|
||||||
|
owner_type_snapshot = owner_type_of(owner)
|
||||||
|
owner_id_snapshot = str(owner.id)
|
||||||
|
attempt_snapshot = int(owner.generation_attempt_no or 1)
|
||||||
|
generation_mode_snapshot = str(getattr(owner, "generation_mode", "") or "") or None
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
owner = await _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
owner_type=owner_type_snapshot,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
attempt_no=attempt_snapshot,
|
||||||
|
)
|
||||||
|
if owner is not None:
|
||||||
await notify_owner_finished(db, owner)
|
await notify_owner_finished(db, owner)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
owner = await _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
owner_type=owner_type_snapshot,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
attempt_no=attempt_snapshot,
|
||||||
|
)
|
||||||
|
if owner is not None:
|
||||||
await _remove_active(owner)
|
await _remove_active(owner)
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
owner,
|
owner_type=owner_type_snapshot,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
generation_attempt_no=attempt_snapshot,
|
||||||
|
generation_mode=generation_mode_snapshot,
|
||||||
event_type=(
|
event_type=(
|
||||||
ChatGenerationTaskEventType.DOWNLOAD_FAILED_NON_RETRYABLE.value
|
ChatGenerationTaskEventType.DOWNLOAD_FAILED_NON_RETRYABLE.value
|
||||||
if non_retryable
|
if non_retryable
|
||||||
@@ -481,7 +543,18 @@ async def _schedule_retry(
|
|||||||
owner.download_celery_task_id = _build_celery_task_id(
|
owner.download_celery_task_id = _build_celery_task_id(
|
||||||
owner, reason="retry"
|
owner, reason="retry"
|
||||||
)
|
)
|
||||||
|
owner_type_snapshot = owner_type_of(owner)
|
||||||
|
owner_id_snapshot = str(owner.id)
|
||||||
|
attempt_snapshot = int(owner.generation_attempt_no or 1)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
owner = await _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
owner_type=owner_type_snapshot,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
attempt_no=attempt_snapshot,
|
||||||
|
)
|
||||||
|
if owner is None:
|
||||||
|
return
|
||||||
await _register_active(
|
await _register_active(
|
||||||
owner,
|
owner,
|
||||||
check_at=owner.download_next_retry_at,
|
check_at=owner.download_next_retry_at,
|
||||||
@@ -555,7 +628,18 @@ async def _restore_after_lock_error(
|
|||||||
0, int(owner.download_attempt_count or 0) - 1
|
0, int(owner.download_attempt_count or 0) - 1
|
||||||
)
|
)
|
||||||
owner.download_enqueued_at = _now()
|
owner.download_enqueued_at = _now()
|
||||||
|
owner_type_snapshot = owner_type_of(owner)
|
||||||
|
owner_id_snapshot = str(owner.id)
|
||||||
|
attempt_snapshot = int(owner.generation_attempt_no or 1)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
owner = await _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
owner_type=owner_type_snapshot,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
attempt_no=attempt_snapshot,
|
||||||
|
)
|
||||||
|
if owner is None:
|
||||||
|
return
|
||||||
await _register_active(
|
await _register_active(
|
||||||
owner,
|
owner,
|
||||||
check_at=_queue_timeout_at(),
|
check_at=_queue_timeout_at(),
|
||||||
@@ -631,7 +715,8 @@ async def _run(
|
|||||||
if not is_attempt_current(owner, effective_attempt):
|
if not is_attempt_current(owner, effective_attempt):
|
||||||
await _remove_active(owner)
|
await _remove_active(owner)
|
||||||
return
|
return
|
||||||
if not await _claim(db, owner, claim_token=lease.token):
|
owner = await _claim(db, owner, claim_token=lease.token)
|
||||||
|
if owner is None:
|
||||||
return
|
return
|
||||||
claimed = True
|
claimed = True
|
||||||
|
|
||||||
@@ -705,15 +790,26 @@ async def _run(
|
|||||||
owner.download_lease_until = None
|
owner.download_lease_until = None
|
||||||
owner.download_next_retry_at = None
|
owner.download_next_retry_at = None
|
||||||
owner.download_last_error = None
|
owner.download_last_error = None
|
||||||
await db.commit()
|
upscale_mode = str(getattr(owner, "generation_mode", "") or "") or None
|
||||||
await _remove_active(owner)
|
upscale_stage = str(owner.pipeline_stage or "") or None
|
||||||
await enqueue_upscale_task(
|
await enqueue_upscale_task(
|
||||||
db, upscale=upscale, reason="source_download_completed"
|
db, upscale=upscale, reason="source_download_completed"
|
||||||
)
|
)
|
||||||
|
owner = await _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
owner_type=normalized_owner_type,
|
||||||
|
owner_id=task_id,
|
||||||
|
attempt_no=effective_attempt,
|
||||||
|
)
|
||||||
|
if owner is not None:
|
||||||
|
await _remove_active(owner)
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
owner,
|
owner_type=normalized_owner_type,
|
||||||
|
owner_id=task_id,
|
||||||
|
generation_attempt_no=effective_attempt,
|
||||||
|
generation_mode=upscale_mode,
|
||||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_SUCCESS.value,
|
event_type=ChatGenerationTaskEventType.DOWNLOAD_SUCCESS.value,
|
||||||
to_stage=owner.pipeline_stage,
|
to_stage=(str(owner.pipeline_stage or "") if owner is not None else upscale_stage),
|
||||||
detail={
|
detail={
|
||||||
"upscale_source_path": downloaded.storage_path
|
"upscale_source_path": downloaded.storage_path
|
||||||
},
|
},
|
||||||
@@ -739,14 +835,33 @@ async def _run(
|
|||||||
owner.download_last_error = None
|
owner.download_last_error = None
|
||||||
await _record_resource(db, owner, downloaded)
|
await _record_resource(db, owner, downloaded)
|
||||||
await _sync_snapshot(db, owner)
|
await _sync_snapshot(db, owner)
|
||||||
|
completion_mode = str(getattr(owner, "generation_mode", "") or "") or None
|
||||||
|
completion_stage = str(owner.pipeline_stage or "") or None
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
owner = await _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
owner_type=normalized_owner_type,
|
||||||
|
owner_id=task_id,
|
||||||
|
attempt_no=effective_attempt,
|
||||||
|
)
|
||||||
|
if owner is not None:
|
||||||
await notify_owner_finished(db, owner)
|
await notify_owner_finished(db, owner)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
owner = await _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
owner_type=normalized_owner_type,
|
||||||
|
owner_id=task_id,
|
||||||
|
attempt_no=effective_attempt,
|
||||||
|
)
|
||||||
|
if owner is not None:
|
||||||
await _remove_active(owner)
|
await _remove_active(owner)
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
owner,
|
owner_type=normalized_owner_type,
|
||||||
|
owner_id=task_id,
|
||||||
|
generation_attempt_no=effective_attempt,
|
||||||
|
generation_mode=completion_mode,
|
||||||
event_type=ChatGenerationTaskEventType.DOWNLOAD_SUCCESS.value,
|
event_type=ChatGenerationTaskEventType.DOWNLOAD_SUCCESS.value,
|
||||||
to_stage=owner.pipeline_stage,
|
to_stage=(str(owner.pipeline_stage or "") if owner is not None else completion_stage),
|
||||||
detail={
|
detail={
|
||||||
"resource_url": downloaded.url,
|
"resource_url": downloaded.url,
|
||||||
"file_size_bytes": downloaded.file_size_bytes,
|
"file_size_bytes": downloaded.file_size_bytes,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from app.enums.generation_task import (
|
|||||||
from app.models.base import async_session
|
from app.models.base import async_session
|
||||||
from app.models.chat_generation_task import ChatGenerationTask
|
from app.models.chat_generation_task import ChatGenerationTask
|
||||||
from app.services.error_codes import extract_error_message
|
from app.services.error_codes import extract_error_message
|
||||||
from app.services.generation.log_service import log_provider_call, log_task_event
|
from app.services.generation.log_service import log_task_event
|
||||||
from app.services.generation.pipeline.db_lock_service import DatabaseRowLockBusy
|
from app.services.generation.pipeline.db_lock_service import DatabaseRowLockBusy
|
||||||
from app.services.generation.pipeline.lifecycle_service import (
|
from app.services.generation.pipeline.lifecycle_service import (
|
||||||
mark_owner_failed_and_refund_once,
|
mark_owner_failed_and_refund_once,
|
||||||
@@ -101,24 +101,6 @@ def _engine_snapshot(owner: GenerationOwner) -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
async def _log_poll_provider_call_after_commit(
|
|
||||||
owner: GenerationOwner,
|
|
||||||
*,
|
|
||||||
provider_response: Any,
|
|
||||||
) -> None:
|
|
||||||
"""Provider logs use an independent session, so the owner row must be committed first."""
|
|
||||||
snapshot = _engine_snapshot(owner)
|
|
||||||
await log_provider_call(
|
|
||||||
owner,
|
|
||||||
provider=snapshot.get("provider") or "ark",
|
|
||||||
api_type=f"{owner.gen_type}_poll",
|
|
||||||
model=snapshot.get("model_name"),
|
|
||||||
engine_id=owner.engine_id,
|
|
||||||
status="success",
|
|
||||||
provider_task_id=owner_provider_task_id(owner),
|
|
||||||
response_data=provider_response,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _registry_id(owner: GenerationOwner) -> str:
|
def _registry_id(owner: GenerationOwner) -> str:
|
||||||
return redis_owner_item_id(
|
return redis_owner_item_id(
|
||||||
@@ -235,6 +217,24 @@ async def remove_poll_active(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
*,
|
||||||
|
owner_type: str,
|
||||||
|
owner_id: str,
|
||||||
|
attempt_no: int,
|
||||||
|
) -> GenerationOwner | None:
|
||||||
|
fresh = await load_generation_owner(
|
||||||
|
db,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
for_update=False,
|
||||||
|
)
|
||||||
|
if fresh is None or not is_attempt_current(fresh, attempt_no):
|
||||||
|
return None
|
||||||
|
return fresh
|
||||||
|
|
||||||
|
|
||||||
async def _sync_snapshot(
|
async def _sync_snapshot(
|
||||||
db, owner: GenerationOwner, provider_response: Any = None
|
db, owner: GenerationOwner, provider_response: Any = None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -266,16 +266,34 @@ async def _mark_failed(
|
|||||||
owner.next_poll_at = None
|
owner.next_poll_at = None
|
||||||
owner.poll_claim_token = None
|
owner.poll_claim_token = None
|
||||||
owner.poll_lease_until = None
|
owner.poll_lease_until = None
|
||||||
|
owner_type_snapshot = owner_type_of(owner)
|
||||||
|
owner_id_snapshot = str(owner.id)
|
||||||
|
attempt_snapshot = int(owner.generation_attempt_no or 1)
|
||||||
|
mode_snapshot = owner_mode(owner)
|
||||||
|
stage_snapshot = str(owner.pipeline_stage or "")
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await notify_owner_finished(db, owner)
|
fresh_owner = await _reload_owner_after_commit(
|
||||||
await db.commit()
|
db,
|
||||||
await remove_poll_active(owner)
|
owner_type=owner_type_snapshot,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
attempt_no=attempt_snapshot,
|
||||||
|
)
|
||||||
|
if fresh_owner is not None:
|
||||||
|
await notify_owner_finished(db, fresh_owner)
|
||||||
|
await remove_poll_active(
|
||||||
|
owner_type=owner_type_snapshot,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
attempt_no=attempt_snapshot,
|
||||||
|
)
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
owner,
|
owner_type=owner_type_snapshot,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
generation_attempt_no=attempt_snapshot,
|
||||||
|
generation_mode=mode_snapshot,
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
message=message,
|
message=message,
|
||||||
detail=detail,
|
detail=detail,
|
||||||
to_stage=owner.pipeline_stage,
|
to_stage=stage_snapshot,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -302,15 +320,30 @@ async def _schedule_next_poll(
|
|||||||
owner.poll_interval_seconds = schedule.poll_interval_seconds
|
owner.poll_interval_seconds = schedule.poll_interval_seconds
|
||||||
owner.poll_claim_token = None
|
owner.poll_claim_token = None
|
||||||
owner.poll_lease_until = None
|
owner.poll_lease_until = None
|
||||||
|
owner_type_snapshot = owner_type_of(owner)
|
||||||
|
owner_id_snapshot = str(owner.id)
|
||||||
|
attempt_snapshot = int(owner.generation_attempt_no or 1)
|
||||||
|
mode_snapshot = owner_mode(owner)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
fresh_owner = await _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
owner_type=owner_type_snapshot,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
attempt_no=attempt_snapshot,
|
||||||
|
)
|
||||||
|
if fresh_owner is None:
|
||||||
|
return
|
||||||
await register_poll_active(
|
await register_poll_active(
|
||||||
owner,
|
fresh_owner,
|
||||||
check_at=schedule.next_poll_at,
|
check_at=schedule.next_poll_at,
|
||||||
next_poll_at=schedule.next_poll_at,
|
next_poll_at=schedule.next_poll_at,
|
||||||
reason=schedule.reason,
|
reason=schedule.reason,
|
||||||
)
|
)
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
owner,
|
owner_type=owner_type_snapshot,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
generation_attempt_no=attempt_snapshot,
|
||||||
|
generation_mode=mode_snapshot,
|
||||||
event_type=ChatGenerationTaskEventType.POLL_SCHEDULED.value,
|
event_type=ChatGenerationTaskEventType.POLL_SCHEDULED.value,
|
||||||
message=f"已登记下一次轮询。reason={schedule.reason}",
|
message=f"已登记下一次轮询。reason={schedule.reason}",
|
||||||
detail={
|
detail={
|
||||||
@@ -320,10 +353,10 @@ async def _schedule_next_poll(
|
|||||||
)
|
)
|
||||||
if schedule.direct_countdown:
|
if schedule.direct_countdown:
|
||||||
poll_generation_task.apply_async(
|
poll_generation_task.apply_async(
|
||||||
args=[owner.id],
|
args=[owner_id_snapshot],
|
||||||
kwargs={
|
kwargs={
|
||||||
"owner_type": owner_type_of(owner),
|
"owner_type": owner_type_snapshot,
|
||||||
"generation_attempt_no": int(owner.generation_attempt_no or 1),
|
"generation_attempt_no": attempt_snapshot,
|
||||||
"force_due": False,
|
"force_due": False,
|
||||||
},
|
},
|
||||||
queue=POLL_QUEUE,
|
queue=POLL_QUEUE,
|
||||||
@@ -381,11 +414,19 @@ async def _restore_after_lock_error(
|
|||||||
owner.poll_claim_token = None
|
owner.poll_claim_token = None
|
||||||
owner.poll_lease_until = None
|
owner.poll_lease_until = None
|
||||||
owner.next_poll_at = _now()
|
owner.next_poll_at = _now()
|
||||||
|
next_poll_at = owner.next_poll_at
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
fresh_owner = await _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
attempt_no=attempt_no,
|
||||||
|
)
|
||||||
|
if fresh_owner is not None:
|
||||||
await register_poll_active(
|
await register_poll_active(
|
||||||
owner,
|
fresh_owner,
|
||||||
check_at=owner.next_poll_at,
|
check_at=next_poll_at,
|
||||||
next_poll_at=owner.next_poll_at,
|
next_poll_at=next_poll_at,
|
||||||
reason="poll_execution_lock_error",
|
reason="poll_execution_lock_error",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -478,11 +519,19 @@ async def _run(
|
|||||||
# A stale database lease left by a crashed worker must not block the
|
# A stale database lease left by a crashed worker must not block the
|
||||||
# worker that successfully acquired the current Redis lock.
|
# worker that successfully acquired the current Redis lock.
|
||||||
if not force_due and is_poll_not_due(owner, now=current):
|
if not force_due and is_poll_not_due(owner, now=current):
|
||||||
|
next_poll_at = owner.next_poll_at
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
owner = await _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
owner_type=normalized_owner_type,
|
||||||
|
owner_id=task_id,
|
||||||
|
attempt_no=effective_attempt,
|
||||||
|
)
|
||||||
|
if owner is not None:
|
||||||
await register_poll_active(
|
await register_poll_active(
|
||||||
owner,
|
owner,
|
||||||
check_at=owner.next_poll_at,
|
check_at=next_poll_at,
|
||||||
next_poll_at=owner.next_poll_at,
|
next_poll_at=next_poll_at,
|
||||||
reason="poll_task_not_due",
|
reason="poll_task_not_due",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -507,12 +556,22 @@ async def _run(
|
|||||||
owner.poll_lease_until = _poll_lease_until(current)
|
owner.poll_lease_until = _poll_lease_until(current)
|
||||||
owner.poll_count = int(owner.poll_count or 0) + 1
|
owner.poll_count = int(owner.poll_count or 0) + 1
|
||||||
owner.last_poll_at = current
|
owner.last_poll_at = current
|
||||||
|
poll_lease_until = owner.poll_lease_until
|
||||||
|
next_poll_at = owner.next_poll_at
|
||||||
await db.commit()
|
await db.commit()
|
||||||
claim_started = True
|
claim_started = True
|
||||||
|
owner = await _reload_owner_after_commit(
|
||||||
|
db,
|
||||||
|
owner_type=normalized_owner_type,
|
||||||
|
owner_id=task_id,
|
||||||
|
attempt_no=effective_attempt,
|
||||||
|
)
|
||||||
|
if owner is None:
|
||||||
|
return
|
||||||
await register_poll_active(
|
await register_poll_active(
|
||||||
owner,
|
owner,
|
||||||
check_at=owner.poll_lease_until,
|
check_at=poll_lease_until,
|
||||||
next_poll_at=owner.next_poll_at,
|
next_poll_at=next_poll_at,
|
||||||
reason="polling_lease",
|
reason="polling_lease",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -562,9 +621,6 @@ async def _run(
|
|||||||
event_type=ChatGenerationTaskEventType.POLL_FAILED.value,
|
event_type=ChatGenerationTaskEventType.POLL_FAILED.value,
|
||||||
detail=poll_result,
|
detail=poll_result,
|
||||||
)
|
)
|
||||||
await _log_poll_provider_call_after_commit(
|
|
||||||
owner, provider_response=provider_response
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
owner.pipeline_stage = _stage(
|
owner.pipeline_stage = _stage(
|
||||||
owner, ChatGenerationPipelineStage.RESULT_READY
|
owner, ChatGenerationPipelineStage.RESULT_READY
|
||||||
@@ -573,16 +629,30 @@ async def _run(
|
|||||||
owner.poll_claim_token = None
|
owner.poll_claim_token = None
|
||||||
owner.poll_lease_until = None
|
owner.poll_lease_until = None
|
||||||
owner.next_poll_at = None
|
owner.next_poll_at = None
|
||||||
|
success_stage = str(owner.pipeline_stage or "")
|
||||||
|
success_mode = owner_mode(owner)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await _log_poll_provider_call_after_commit(
|
owner = await _reload_owner_after_commit(
|
||||||
owner, provider_response=provider_response
|
db,
|
||||||
|
owner_type=normalized_owner_type,
|
||||||
|
owner_id=task_id,
|
||||||
|
attempt_no=effective_attempt,
|
||||||
|
)
|
||||||
|
await remove_poll_active(
|
||||||
|
owner_type=normalized_owner_type,
|
||||||
|
owner_id=task_id,
|
||||||
|
attempt_no=effective_attempt,
|
||||||
)
|
)
|
||||||
await remove_poll_active(owner)
|
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
owner,
|
owner_type=normalized_owner_type,
|
||||||
|
owner_id=task_id,
|
||||||
|
generation_attempt_no=effective_attempt,
|
||||||
|
generation_mode=success_mode,
|
||||||
event_type=ChatGenerationTaskEventType.POLL_SUCCESS.value,
|
event_type=ChatGenerationTaskEventType.POLL_SUCCESS.value,
|
||||||
to_stage=owner.pipeline_stage,
|
to_stage=success_stage,
|
||||||
)
|
)
|
||||||
|
if owner is None:
|
||||||
|
return
|
||||||
from app.tasks.generation_download_tasks import (
|
from app.tasks.generation_download_tasks import (
|
||||||
enqueue_download_task,
|
enqueue_download_task,
|
||||||
)
|
)
|
||||||
@@ -603,9 +673,6 @@ async def _run(
|
|||||||
event_type=ChatGenerationTaskEventType.POLL_FAILED.value,
|
event_type=ChatGenerationTaskEventType.POLL_FAILED.value,
|
||||||
detail=poll_result,
|
detail=poll_result,
|
||||||
)
|
)
|
||||||
await _log_poll_provider_call_after_commit(
|
|
||||||
owner, provider_response=provider_response
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if final_poll:
|
if final_poll:
|
||||||
@@ -617,18 +684,12 @@ async def _run(
|
|||||||
event_type=ChatGenerationTaskEventType.TASK_TIMEOUT.value,
|
event_type=ChatGenerationTaskEventType.TASK_TIMEOUT.value,
|
||||||
detail=poll_result,
|
detail=poll_result,
|
||||||
)
|
)
|
||||||
await _log_poll_provider_call_after_commit(
|
|
||||||
owner, provider_response=provider_response
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
owner.poll_error_count = 0
|
owner.poll_error_count = 0
|
||||||
await _schedule_next_poll(
|
await _schedule_next_poll(
|
||||||
db, owner, reason="poll_pending_next"
|
db, owner, reason="poll_pending_next"
|
||||||
)
|
)
|
||||||
await _log_poll_provider_call_after_commit(
|
|
||||||
owner, provider_response=provider_response
|
|
||||||
)
|
|
||||||
await log_task_event(
|
await log_task_event(
|
||||||
owner,
|
owner,
|
||||||
event_type=ChatGenerationTaskEventType.POLL_PENDING.value,
|
event_type=ChatGenerationTaskEventType.POLL_PENDING.value,
|
||||||
|
|||||||
@@ -27,12 +27,97 @@ async def _recover_generation_records_once(*, include_create: bool, include_poll
|
|||||||
from app.tasks.generation_poll_tasks import poll_generation_task
|
from app.tasks.generation_poll_tasks import poll_generation_task
|
||||||
from app.tasks.generation_download_tasks import enqueue_download_task
|
from app.tasks.generation_download_tasks import enqueue_download_task
|
||||||
|
|
||||||
counts: dict[str, Any] = {"create": 0, "poll": 0, "download": 0, "errors": []}
|
counts: dict[str, Any] = {
|
||||||
|
"create": 0,
|
||||||
|
"poll": 0,
|
||||||
|
"download": 0,
|
||||||
|
"inconsistent": 0,
|
||||||
|
"inconsistent_timeout": 0,
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
batch_size = max(1, int(settings.GENERATION_RECOVERY_BATCH_SIZE or 20))
|
batch_size = max(1, int(settings.GENERATION_RECOVERY_BATCH_SIZE or 20))
|
||||||
cursor = None
|
cursor = None
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
while True:
|
while True:
|
||||||
batch = await find_generation_record_recovery_batch(db, limit=batch_size, cursor=cursor)
|
batch = await find_generation_record_recovery_batch(db, limit=batch_size, cursor=cursor)
|
||||||
|
if include_create or include_poll:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from app.enums.generation_status import GenerationRecordPipelineStage
|
||||||
|
from app.enums.generation_task import ChatGenerationTaskEventType, GenerationMode
|
||||||
|
from app.services.generation.log_service import log_task_event
|
||||||
|
from app.services.generation.pipeline.owner_service import load_generation_owner
|
||||||
|
from app.services.generation.refund_service import mark_generation_record_failed_and_refund_once
|
||||||
|
from app.services.redis_registry_service import ensure_aware_utc
|
||||||
|
|
||||||
|
for ref in batch.inconsistent:
|
||||||
|
try:
|
||||||
|
owner = await load_generation_owner(
|
||||||
|
db,
|
||||||
|
owner_type=ref.owner_type,
|
||||||
|
owner_id=ref.owner_id,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
owner is None
|
||||||
|
or int(owner.generation_attempt_no or 1)
|
||||||
|
!= int(ref.generation_attempt_no or 1)
|
||||||
|
):
|
||||||
|
await db.rollback()
|
||||||
|
continue
|
||||||
|
deadline_at = ensure_aware_utc(getattr(owner, "deadline_at", None))
|
||||||
|
owner_id_snapshot = str(owner.id)
|
||||||
|
attempt_snapshot = int(owner.generation_attempt_no or 1)
|
||||||
|
previous_stage = str(owner.pipeline_stage or "")
|
||||||
|
if deadline_at is not None and deadline_at <= datetime.now(timezone.utc):
|
||||||
|
owner.pipeline_stage = GenerationRecordPipelineStage.TIMEOUT.value
|
||||||
|
await mark_generation_record_failed_and_refund_once(
|
||||||
|
db,
|
||||||
|
record=owner,
|
||||||
|
generation_attempt_no=ref.generation_attempt_no,
|
||||||
|
error_message="恢复证据异常且已超过任务截止时间",
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await log_task_event(
|
||||||
|
owner_type=ref.owner_type,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
generation_attempt_no=attempt_snapshot,
|
||||||
|
generation_mode=GenerationMode.GENERATION_RECORD.value,
|
||||||
|
event_type=ChatGenerationTaskEventType.TASK_TIMEOUT.value,
|
||||||
|
from_stage=previous_stage,
|
||||||
|
to_stage=GenerationRecordPipelineStage.TIMEOUT.value,
|
||||||
|
message="恢复证据异常任务超过截止时间,已失败并幂等退款",
|
||||||
|
)
|
||||||
|
counts["inconsistent_timeout"] += 1
|
||||||
|
continue
|
||||||
|
if previous_stage != GenerationRecordPipelineStage.RECOVERY_INCONSISTENT.value:
|
||||||
|
owner.pipeline_stage = GenerationRecordPipelineStage.RECOVERY_INCONSISTENT.value
|
||||||
|
owner.error_message = (
|
||||||
|
f"恢复证据异常:阶段 {previous_stage} 缺少 remote_result_url 和供应商任务ID"
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await log_task_event(
|
||||||
|
owner_type=ref.owner_type,
|
||||||
|
owner_id=owner_id_snapshot,
|
||||||
|
generation_attempt_no=attempt_snapshot,
|
||||||
|
generation_mode=GenerationMode.GENERATION_RECORD.value,
|
||||||
|
event_type=ChatGenerationTaskEventType.GENERATION_RECOVERY_INCONSISTENT.value,
|
||||||
|
from_stage=previous_stage,
|
||||||
|
to_stage=GenerationRecordPipelineStage.RECOVERY_INCONSISTENT.value,
|
||||||
|
message="恢复证据异常,已隔离且不重新创建供应商任务",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await db.rollback()
|
||||||
|
counts["inconsistent"] += 1
|
||||||
|
except Exception as exc:
|
||||||
|
await db.rollback()
|
||||||
|
counts["errors"].append(
|
||||||
|
{
|
||||||
|
"owner_id": ref.owner_id,
|
||||||
|
"stage": "recovery_inconsistent",
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
if include_create:
|
if include_create:
|
||||||
for ref in batch.create:
|
for ref in batch.create:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { api, setToken, clearToken } from './client';
|
import { api, setToken, clearToken } from './client';
|
||||||
import * as mock from './mock';
|
import * as mock from './mock';
|
||||||
import type {
|
import type {
|
||||||
User, CreditRecord, Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult,
|
User, CreditRecord, Project, GenerationRecord, OptimizeParams, OptimizeResult,
|
||||||
Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
|
Industry, IndustryConfig, AdminUser, AdminStats, ModelConfig, SystemConfig, AdminNotification,
|
||||||
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitProject, PrivatePortraitValidateSession,
|
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitProject, PrivatePortraitValidateSession,
|
||||||
PrivatePortraitProjectCreateWithValidateOut, PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut,
|
PrivatePortraitProjectCreateWithValidateOut, PrivatePortraitAssetListOut, PrivatePortraitAsset, PrivatePortraitSelectableAssetListOut,
|
||||||
@@ -112,7 +112,11 @@ export async function optimizePrompt(
|
|||||||
project_id: projectId,//项目id
|
project_id: projectId,//项目id
|
||||||
gen_type:params.genType,//生成类型
|
gen_type:params.genType,//生成类型
|
||||||
prompt: params.prompt,
|
prompt: params.prompt,
|
||||||
|
engine_id: params.engineId,
|
||||||
|
include_media_references: params.includeMediaReferences ?? false,
|
||||||
duration: params.duration,
|
duration: params.duration,
|
||||||
|
aspect_ratio: params.aspectRatio || null,
|
||||||
|
resolution: params.resolution || null,
|
||||||
references: params.references || null,
|
references: params.references || null,
|
||||||
idempotency_key: params.idempotencyKey || null,
|
idempotency_key: params.idempotencyKey || null,
|
||||||
image_size: params.image_size || null,
|
image_size: params.image_size || null,
|
||||||
@@ -266,15 +270,14 @@ export async function deleteUpload(url: string): Promise<void> {
|
|||||||
export async function updateRecordPrompt(recordId: string, optimizedPrompt: string): Promise<void> {
|
export async function updateRecordPrompt(recordId: string, optimizedPrompt: string): Promise<void> {
|
||||||
await api.put(`/generation-records/${recordId}/prompt`, { optimized_prompt: optimizedPrompt });
|
await api.put(`/generation-records/${recordId}/prompt`, { optimized_prompt: optimizedPrompt });
|
||||||
}
|
}
|
||||||
export async function generateVideo(recordId: string, params: GenerateParams): Promise<GenerationRecord> {
|
export async function generateVideo(recordId: string): Promise<GenerationRecord> {
|
||||||
if (USE_MOCK) return mock.mockGenerateVideo(recordId);
|
if (USE_MOCK) return mock.mockGenerateVideo(recordId);
|
||||||
return api.post<GenerationRecord>(`/generation-records/${recordId}/generate`, {
|
return api.post<GenerationRecord>(`/generation-records/${recordId}/generate`);
|
||||||
engine_id: params.engineId,
|
}
|
||||||
include_media_references: params.includeMediaReferences ?? false,
|
|
||||||
aspect_ratio: params.aspectRatio,
|
export async function retryGeneration(recordId: string): Promise<GenerationRecord> {
|
||||||
resolution: params.resolution,
|
if (USE_MOCK) return mock.mockGenerateVideo(recordId);
|
||||||
image_size: params.imageSize,
|
return api.post<GenerationRecord>(`/generation-records/${recordId}/retry`);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// ── Credits ───────────────────────────────────────────────
|
// ── Credits ───────────────────────────────────────────────
|
||||||
export async function getCredits(page = 1, pageSize = 20): Promise<{ credits: number; records: CreditRecord[]; total: number }> {
|
export async function getCredits(page = 1, pageSize = 20): Promise<{ credits: number; records: CreditRecord[]; total: number }> {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type {
|
|||||||
CreditRecord,
|
CreditRecord,
|
||||||
Project,
|
Project,
|
||||||
GenerationRecord,
|
GenerationRecord,
|
||||||
GenerateParams,
|
|
||||||
OptimizeParams,
|
OptimizeParams,
|
||||||
OptimizeResult,
|
OptimizeResult,
|
||||||
LoginParams,
|
LoginParams,
|
||||||
@@ -208,7 +207,7 @@ export async function mockOptimizePrompt(
|
|||||||
await delay(1500);
|
await delay(1500);
|
||||||
|
|
||||||
const project = MOCK_PROJECTS.find((p) => p.id === projectId);
|
const project = MOCK_PROJECTS.find((p) => p.id === projectId);
|
||||||
const cost = Math.round(80 + params.prompt.length * 0.5 + params.duration * 2);
|
const cost = Math.round(80 + params.prompt.length * 0.5 + (params.duration || 0) * 2);
|
||||||
|
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
currentUser.credits -= cost;
|
currentUser.credits -= cost;
|
||||||
@@ -239,18 +238,28 @@ export async function mockOptimizePrompt(
|
|||||||
projectName: project?.name ?? '未知项目',
|
projectName: project?.name ?? '未知项目',
|
||||||
originalPrompt: params.prompt,
|
originalPrompt: params.prompt,
|
||||||
optimizedPrompt,
|
optimizedPrompt,
|
||||||
duration: params.duration,
|
duration: params.genType === 'video' ? params.duration : undefined,
|
||||||
aspectRatio: params.aspectRatio as any,
|
aspectRatio: params.genType === 'video' ? params.aspectRatio as any : undefined,
|
||||||
resolution: params.resolution as any,
|
resolution: params.genType === 'video' ? params.resolution as any : undefined,
|
||||||
status: 'prompt_optimized',
|
status: 'prompt_optimized',
|
||||||
creditsCost: cost,
|
creditsCost: cost,
|
||||||
textCreditsCost: cost,
|
textCreditsCost: cost,
|
||||||
textTokensUsed: 0,
|
textTokensUsed: 0,
|
||||||
videoTokensUsed: 0,
|
videoTokensUsed: 0,
|
||||||
imageSize: params.resolution || '1080p',
|
imageSize: params.genType === 'image' ? params.image_size : undefined,
|
||||||
imageProportion: params.aspectRatio || '16:9',
|
imageProportion: params.genType === 'image' ? params.image_proportion : undefined,
|
||||||
imagePx: '1920x1080',
|
imagePx: params.genType === 'image' ? params.image_px : undefined,
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
|
engineId: params.engineId,
|
||||||
|
engineName: params.engineId,
|
||||||
|
engineSnapshot: { id: params.engineId, name: params.engineId },
|
||||||
|
includeMediaReferences: Boolean(params.includeMediaReferences),
|
||||||
|
configComplete: true,
|
||||||
|
canGenerate: true,
|
||||||
|
canRetry: false,
|
||||||
|
shouldPoll: false,
|
||||||
|
clientStatus: 'ready',
|
||||||
|
operationPhase: 'prompt',
|
||||||
createdAt: new Date().toLocaleString('zh-CN'),
|
createdAt: new Date().toLocaleString('zh-CN'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -265,7 +274,18 @@ export async function mockGenerateVideo(recordId: string): Promise<GenerationRec
|
|||||||
if (!record) throw new Error('记录不存在');
|
if (!record) throw new Error('记录不存在');
|
||||||
|
|
||||||
record.status = 'completed';
|
record.status = 'completed';
|
||||||
|
record.canGenerate = false;
|
||||||
|
record.canRetry = false;
|
||||||
|
record.shouldPoll = false;
|
||||||
|
record.clientStatus = 'success';
|
||||||
|
record.operationPhase = 'resource';
|
||||||
|
if (record.genType === 'image') {
|
||||||
|
record.imageUrl = `https://example.com/image-${recordId}.png`;
|
||||||
|
record.videoUrl = undefined;
|
||||||
|
} else {
|
||||||
record.videoUrl = `https://example.com/video-${recordId}.mp4`;
|
record.videoUrl = `https://example.com/video-${recordId}.mp4`;
|
||||||
|
record.imageUrl = undefined;
|
||||||
|
}
|
||||||
record.generatedAt = new Date().toLocaleString('zh-CN');
|
record.generatedAt = new Date().toLocaleString('zh-CN');
|
||||||
|
|
||||||
return record;
|
return record;
|
||||||
|
|||||||
@@ -502,6 +502,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
fetchRecords,
|
fetchRecords,
|
||||||
optimizePrompt,
|
optimizePrompt,
|
||||||
generateVideo,
|
generateVideo,
|
||||||
|
retryGeneration,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const recordItems = records.items;
|
const recordItems = records.items;
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
@@ -635,11 +636,6 @@ const GeneratePage: React.FC = () => {
|
|||||||
Record<string, string>
|
Record<string, string>
|
||||||
>({});
|
>({});
|
||||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||||
// Per-record param selections for history prompt_optimized records
|
|
||||||
const [historyParams, setHistoryParams] = useState<
|
|
||||||
Record<string, { aspectRatio?: AspectRatio; resolution?: Resolution; engineId?: string; includeMediaReferences?: boolean }>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
// Video preview modal
|
// Video preview modal
|
||||||
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
|
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -1301,7 +1297,11 @@ const GeneratePage: React.FC = () => {
|
|||||||
const pending = localStorage.getItem("pending_optimize");
|
const pending = localStorage.getItem("pending_optimize");
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
try {
|
try {
|
||||||
const { key, projectId: pId, prompt, duration, ts } = JSON.parse(pending);
|
const {
|
||||||
|
key, projectId: pId, prompt, duration, engineId, genType, aspectRatio,
|
||||||
|
resolution, imageSize, imageProportion, imagePx, includeMediaReferences: pendingIncludeReferences,
|
||||||
|
referenceIds, ts,
|
||||||
|
} = JSON.parse(pending);
|
||||||
if (Date.now() - ts > 5 * 60 * 1000 || pId !== projectId) {
|
if (Date.now() - ts > 5 * 60 * 1000 || pId !== projectId) {
|
||||||
localStorage.removeItem("pending_optimize");
|
localStorage.removeItem("pending_optimize");
|
||||||
return;
|
return;
|
||||||
@@ -1314,6 +1314,15 @@ const GeneratePage: React.FC = () => {
|
|||||||
r.projectId === pId &&
|
r.projectId === pId &&
|
||||||
r.originalPrompt === prompt &&
|
r.originalPrompt === prompt &&
|
||||||
r.duration === duration &&
|
r.duration === duration &&
|
||||||
|
r.engineId === engineId &&
|
||||||
|
String(r.genType || "video") === String(genType || "video") &&
|
||||||
|
String(r.aspectRatio || "") === String(aspectRatio || "") &&
|
||||||
|
String(r.resolution || "") === String(resolution || "") &&
|
||||||
|
String(r.imageSize || "") === String(imageSize || "") &&
|
||||||
|
String(r.imageProportion || "") === String(imageProportion || "") &&
|
||||||
|
String(r.imagePx || "") === String(imagePx || "") &&
|
||||||
|
Boolean(r.includeMediaReferences) === Boolean(pendingIncludeReferences) &&
|
||||||
|
JSON.stringify((r.references || []).map((item) => item.upload_resource_id || item.url).filter(Boolean).sort()) === JSON.stringify((referenceIds || []).slice().sort()) &&
|
||||||
r.status === "prompt_optimized" &&
|
r.status === "prompt_optimized" &&
|
||||||
new Date(r.createdAt).getTime() > ts - 10000,
|
new Date(r.createdAt).getTime() > ts - 10000,
|
||||||
);
|
);
|
||||||
@@ -1370,8 +1379,19 @@ const GeneratePage: React.FC = () => {
|
|||||||
|
|
||||||
// Media credits estimate for step 2 (video or image)
|
// Media credits estimate for step 2 (video or image)
|
||||||
const estimatedVideoCredits = mediaType === "image"
|
const estimatedVideoCredits = mediaType === "image"
|
||||||
? getImageCreditsFromCimage(selectedResolution)
|
? getImageCreditsFromCimage(
|
||||||
: calcVideoCredits(videoDuration, videoResolution);
|
currentRecord?.imageSize || selectedResolution,
|
||||||
|
currentRecord?.engineId || selectedEngineId,
|
||||||
|
currentRecord?.includeMediaReferences ?? includeMediaReferences,
|
||||||
|
currentRecord?.references || references,
|
||||||
|
)
|
||||||
|
: calcVideoCredits(
|
||||||
|
currentRecord?.duration || videoDuration,
|
||||||
|
(currentRecord?.resolution || videoResolution) as Resolution,
|
||||||
|
currentRecord?.engineId || selectedEngineId,
|
||||||
|
currentRecord?.includeMediaReferences ?? includeMediaReferences,
|
||||||
|
currentRecord?.references || references,
|
||||||
|
);
|
||||||
const canAffordVideo = userCredits >= estimatedVideoCredits;
|
const canAffordVideo = userCredits >= estimatedVideoCredits;
|
||||||
|
|
||||||
// Step 1: Optimize prompt (text credits)
|
// Step 1: Optimize prompt (text credits)
|
||||||
@@ -1386,6 +1406,14 @@ const GeneratePage: React.FC = () => {
|
|||||||
message.error("请输入视频/图片描述");
|
message.error("请输入视频/图片描述");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!selectedEngineId) {
|
||||||
|
message.error("请选择生成引擎");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mediaType === "video" && (!videoAspectRatio || !videoResolution)) {
|
||||||
|
message.error("请选择视频比例和分辨率");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setOptimizing(true);
|
setOptimizing(true);
|
||||||
const optionEntries = Object.entries(selectedOptions).map(
|
const optionEntries = Object.entries(selectedOptions).map(
|
||||||
@@ -1404,20 +1432,32 @@ const GeneratePage: React.FC = () => {
|
|||||||
projectId,
|
projectId,
|
||||||
prompt: fullPrompt,
|
prompt: fullPrompt,
|
||||||
duration: videoDuration,
|
duration: videoDuration,
|
||||||
|
engineId: selectedEngineId,
|
||||||
|
genType: mediaType,
|
||||||
|
aspectRatio: mediaType === "video" ? videoAspectRatio : "",
|
||||||
|
resolution: mediaType === "video" ? videoResolution : "",
|
||||||
|
imageSize: mediaType === "image" ? selectedResolution : "",
|
||||||
|
imageProportion: mediaType === "image" ? selectedRatio : "",
|
||||||
|
imagePx: mediaType === "image" ? `${width}x${height}` : "",
|
||||||
|
includeMediaReferences,
|
||||||
|
referenceIds: references.map((item) => item.upload_resource_id || item.url).filter(Boolean).sort(),
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
inFlightOptimizeKey.current = idempotencyKey;
|
inFlightOptimizeKey.current = idempotencyKey;
|
||||||
const result = await optimizePrompt(projectId, {
|
const result = await optimizePrompt(projectId, {
|
||||||
prompt: fullPrompt,
|
prompt: fullPrompt,
|
||||||
duration: videoDuration,
|
duration: mediaType === "video" ? videoDuration : undefined,
|
||||||
genType: mediaType,
|
genType: mediaType,
|
||||||
resolution: selectedResolution,
|
engineId: selectedEngineId,
|
||||||
|
includeMediaReferences,
|
||||||
|
aspectRatio: mediaType === "video" ? videoAspectRatio : undefined,
|
||||||
|
resolution: mediaType === "video" ? videoResolution : undefined,
|
||||||
references: references.length > 0 ? references : undefined,
|
references: references.length > 0 ? references : undefined,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
image_size: selectedResolution,
|
image_size: mediaType === "image" ? selectedResolution : undefined,
|
||||||
image_proportion: selectedRatio,
|
image_proportion: mediaType === "image" ? selectedRatio : undefined,
|
||||||
image_px: width + "x" + height,
|
image_px: mediaType === "image" ? width + "x" + height : undefined,
|
||||||
});
|
});
|
||||||
// console.log("按钮触发", result);
|
// console.log("按钮触发", result);
|
||||||
|
|
||||||
@@ -1542,7 +1582,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
const frontendState = recordStates[item.id];
|
const frontendState = recordStates[item.id];
|
||||||
// 如果后端状态不是generating,说明任务已经完成(成功或失败),需要处理
|
// 如果后端状态不是generating,说明任务已经完成(成功或失败),需要处理
|
||||||
// 不管前端当前是什么状态,都要处理完成的任务
|
// 不管前端当前是什么状态,都要处理完成的任务
|
||||||
if (latest.status !== "generating") {
|
if (!(latest.shouldPoll ?? latest.status === "generating")) {
|
||||||
completedIds.push(item.id);
|
completedIds.push(item.id);
|
||||||
} else if (frontendState !== "generating") {
|
} else if (frontendState !== "generating") {
|
||||||
// 如果后端状态是generating,但前端不是,更新为generating(处理页面刷新后状态丢失的情况)
|
// 如果后端状态是generating,但前端不是,更新为generating(处理页面刷新后状态丢失的情况)
|
||||||
@@ -1617,7 +1657,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const generatingRecords = recordItems.filter((r) => r.status === "generating");
|
const generatingRecords = recordItems.filter((r) => r.shouldPoll ?? r.status === "generating");
|
||||||
generatingRecords.forEach((record) => {
|
generatingRecords.forEach((record) => {
|
||||||
setRecordStates((p) => ({ ...p, [record.id]: "generating" }));
|
setRecordStates((p) => ({ ...p, [record.id]: "generating" }));
|
||||||
startPolling(record.id);
|
startPolling(record.id);
|
||||||
@@ -1625,10 +1665,6 @@ const GeneratePage: React.FC = () => {
|
|||||||
}, [recordItems]);
|
}, [recordItems]);
|
||||||
|
|
||||||
const handleGenerate = async (recordId: string) => {
|
const handleGenerate = async (recordId: string) => {
|
||||||
if (!canAffordVideo) {
|
|
||||||
message.error("积分不足,请先充值");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setGenerating((p) => ({ ...p, [recordId]: true }));
|
setGenerating((p) => ({ ...p, [recordId]: true }));
|
||||||
setRecordStates((p) => ({ ...p, [recordId]: "generating" }));
|
setRecordStates((p) => ({ ...p, [recordId]: "generating" }));
|
||||||
message.loading({
|
message.loading({
|
||||||
@@ -1637,13 +1673,15 @@ const GeneratePage: React.FC = () => {
|
|||||||
key: recordId,
|
key: recordId,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const result = await generateVideo(recordId, {
|
const record = projectRecords.find((item) => item.id === recordId) || currentRecord;
|
||||||
engineId: selectedEngineId || undefined,
|
if (!record || record.canGenerate === false) {
|
||||||
includeMediaReferences,
|
throw new Error(record?.configFallbackHint || "该记录当前不可生成,请重新生成提词");
|
||||||
aspectRatio: videoAspectRatio,
|
}
|
||||||
resolution: videoResolution,
|
const nextPrompt = record.id === currentRecord?.id ? editedPrompt : editablePrompts[recordId];
|
||||||
imageSize: currentRecord?.imageSize || selectedResolution,
|
if (nextPrompt && nextPrompt !== record.optimizedPrompt) {
|
||||||
});
|
await updateRecordPrompt(recordId, nextPrompt);
|
||||||
|
}
|
||||||
|
const result = await generateVideo(recordId);
|
||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
|
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
|
||||||
message.error({
|
message.error({
|
||||||
@@ -1698,18 +1736,10 @@ const GeneratePage: React.FC = () => {
|
|||||||
key: recordId,
|
key: recordId,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
// Save edited prompt first if changed
|
if (!record.configComplete || record.canRetry === false) {
|
||||||
const editedPrompt = editablePrompts[recordId];
|
throw new Error("该失败记录不能直接重试,请重新生成提词");
|
||||||
if (editedPrompt && editedPrompt !== record.optimizedPrompt) {
|
|
||||||
await updateRecordPrompt(recordId, editedPrompt);
|
|
||||||
}
|
}
|
||||||
const result = await generateVideo(recordId, {
|
const result = await retryGeneration(recordId);
|
||||||
engineId: record.engineId || selectedEngineId || undefined,
|
|
||||||
includeMediaReferences: Boolean(record.includeMediaReferences),
|
|
||||||
aspectRatio: record.aspectRatio || "16:9",
|
|
||||||
resolution: record.resolution || "720p",
|
|
||||||
imageSize: record.imageSize,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.status === "failed") {
|
if (result.status === "failed") {
|
||||||
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
|
setRecordStates((p) => ({ ...p, [recordId]: "failed" }));
|
||||||
@@ -1758,6 +1788,10 @@ const GeneratePage: React.FC = () => {
|
|||||||
return resolveGenerationUiState({
|
return resolveGenerationUiState({
|
||||||
status: localStatus || record.status,
|
status: localStatus || record.status,
|
||||||
pipelineStage: localState ? null : record.pipelineStage,
|
pipelineStage: localState ? null : record.pipelineStage,
|
||||||
|
clientStatus: localState ? null : record.clientStatus,
|
||||||
|
shouldPoll: localState ? localState === "generating" : record.shouldPoll,
|
||||||
|
canGenerate: record.canGenerate,
|
||||||
|
canRetry: record.canRetry,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2792,6 +2826,36 @@ const GeneratePage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
marginRight: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={selectedEngineId || undefined}
|
||||||
|
onChange={handleGenerationEngineChange}
|
||||||
|
placeholder="选择生成引擎"
|
||||||
|
style={{ minWidth: 180 }}
|
||||||
|
options={(mediaType === "image" ? imageEngines : videoEngines).map((item: any) => ({ value: item.id, label: item.name }))}
|
||||||
|
/>
|
||||||
|
{mediaType === "video" && (
|
||||||
|
<>
|
||||||
|
<Select value={videoAspectRatio} onChange={(value) => setVideoAspectRatio(value as AspectRatio)} style={{ width: 110 }} options={engineOptions.ratios.map((value) => ({ value, label: value }))} />
|
||||||
|
<Select value={videoResolution} onChange={(value) => setVideoResolution(value as Resolution)} style={{ width: 110 }} options={engineOptions.resolutions.map((value) => ({ value, label: value }))} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{references.length > 0 && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<Switch size="small" checked={includeMediaReferences} onChange={setIncludeMediaReferences} />
|
||||||
|
<Typography.Text style={{ fontSize: 12 }}>生成时携带附件</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -3273,57 +3337,6 @@ const GeneratePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Video params selection */}
|
|
||||||
{/* {mediaType !== 'image' && (
|
|
||||||
<div style={{ marginTop: 24 }}>
|
|
||||||
<Typography.Text style={{ fontSize: 13, color: '#1a1a2e', fontWeight: 600, display: 'block', marginBottom: 12 }}>选择视频参数</Typography.Text>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 12px', borderRadius: 8, background: '#f8f9fc', border: '1px solid #e2e8f0' }}>
|
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>时长</Typography.Text>
|
|
||||||
<Typography.Text strong style={{ fontSize: 13, color: '#6366f1' }}>{videoDuration}s</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<PortalDropdown label="比例" value={videoAspectRatio}
|
|
||||||
options={engineOptions.ratios}
|
|
||||||
expanded={expandedEngine === 'ratio'}
|
|
||||||
onToggle={() => setExpandedEngine(expandedEngine === 'ratio' ? null : 'ratio')}
|
|
||||||
onSelect={(v) => setVideoAspectRatio(v as AspectRatio)}
|
|
||||||
onClose={() => setExpandedEngine(null)}
|
|
||||||
/>
|
|
||||||
<PortalDropdown label="分辨率" value={videoResolution}
|
|
||||||
options={engineOptions.resolutions}
|
|
||||||
expanded={expandedEngine === 'resolution'}
|
|
||||||
onToggle={() => setExpandedEngine(expandedEngine === 'resolution' ? null : 'resolution')}
|
|
||||||
onSelect={(v) => setVideoResolution(v as Resolution)}
|
|
||||||
onClose={() => setExpandedEngine(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
{/* Image params selection
|
|
||||||
{mediaType === 'image' && currentRecord && (
|
|
||||||
<div style={{ marginTop: 24, padding: 16, borderRadius: 12, background: '#f8f9fc' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-around' }}>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>分辨率</Typography.Text>
|
|
||||||
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>{currentRecord.imageSize || '-'}</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>画面比例</Typography.Text>
|
|
||||||
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>{currentRecord.imageProportion || '-'}</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>画面尺寸</Typography.Text>
|
|
||||||
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>{currentRecord.imagePx || '-'}</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block', marginBottom: 4 }}>消耗积分</Typography.Text>
|
|
||||||
<Typography.Text strong style={{ fontSize: 14, color: '#1a1a2e' }}>-</Typography.Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
{/* Video credits + generate button */}
|
{/* Video credits + generate button */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -3340,17 +3353,27 @@ const GeneratePage: React.FC = () => {
|
|||||||
<div style={{ minWidth: 220, marginRight: 16 }}>
|
<div style={{ minWidth: 220, marginRight: 16 }}>
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>生成引擎</Typography.Text>
|
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>生成引擎</Typography.Text>
|
||||||
<Select
|
<Select
|
||||||
value={selectedEngineId || undefined}
|
value={currentRecord.engineId || undefined}
|
||||||
onChange={handleGenerationEngineChange}
|
disabled
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
options={(mediaType === 'image' ? imageEngines : videoEngines).map((item: any) => ({ value: item.id, label: item.name }))}
|
options={(mediaType === 'image' ? imageEngines : videoEngines).map((item: any) => ({ value: item.id, label: item.name }))}
|
||||||
/>
|
/>
|
||||||
{currentRecord?.references?.length ? (
|
{currentRecord?.references?.length ? (
|
||||||
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div style={{ marginTop: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Switch size="small" checked={includeMediaReferences} onChange={setIncludeMediaReferences} />
|
<Switch size="small" checked={Boolean(currentRecord.includeMediaReferences)} disabled />
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#64748b' }}>携带参考附件生成</Typography.Text>
|
<Typography.Text style={{ fontSize: 12, color: '#64748b' }}>提词阶段已冻结附件配置</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{currentRecord.configComplete === false && (
|
||||||
|
<Typography.Text
|
||||||
|
type={currentRecord.canGenerate === false ? "danger" : "warning"}
|
||||||
|
style={{ fontSize: 12, display: "block", marginTop: 8 }}
|
||||||
|
>
|
||||||
|
{currentRecord.configFallbackHint || (currentRecord.canGenerate === false
|
||||||
|
? "旧版本配置不完整,请重新生成提词"
|
||||||
|
: "旧版本配置缺失,提交生成时将由后端自动补齐")}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Video params selection */}
|
{/* Video params selection */}
|
||||||
@@ -3372,7 +3395,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
选择视频参数
|
已冻结视频参数
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -3400,33 +3423,25 @@ const GeneratePage: React.FC = () => {
|
|||||||
strong
|
strong
|
||||||
style={{ fontSize: 13, color: "#6366f1" }}
|
style={{ fontSize: 13, color: "#6366f1" }}
|
||||||
>
|
>
|
||||||
{videoDuration}s
|
{currentRecord.duration || videoDuration}s
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<PortalDropdown
|
<PortalDropdown
|
||||||
label="比例"
|
label="比例"
|
||||||
value={videoAspectRatio}
|
value={(currentRecord.aspectRatio || videoAspectRatio) as string}
|
||||||
options={engineOptions.ratios}
|
options={[String(currentRecord.aspectRatio || videoAspectRatio)]}
|
||||||
expanded={expandedEngine === "ratio"}
|
expanded={false}
|
||||||
onToggle={() =>
|
onToggle={() => undefined}
|
||||||
setExpandedEngine(
|
onSelect={() => undefined}
|
||||||
expandedEngine === "ratio" ? null : "ratio",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onSelect={(v) => setVideoAspectRatio(v as AspectRatio)}
|
|
||||||
onClose={() => setExpandedEngine(null)}
|
onClose={() => setExpandedEngine(null)}
|
||||||
/>
|
/>
|
||||||
<PortalDropdown
|
<PortalDropdown
|
||||||
label="分辨率"
|
label="分辨率"
|
||||||
value={videoResolution}
|
value={(currentRecord.resolution || videoResolution) as string}
|
||||||
options={engineOptions.resolutions}
|
options={[String(currentRecord.resolution || videoResolution)]}
|
||||||
expanded={expandedEngine === "resolution"}
|
expanded={false}
|
||||||
onToggle={() =>
|
onToggle={() => undefined}
|
||||||
setExpandedEngine(
|
onSelect={() => undefined}
|
||||||
expandedEngine === "resolution" ? null : "resolution",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onSelect={(v) => setVideoResolution(v as Resolution)}
|
|
||||||
onClose={() => setExpandedEngine(null)}
|
onClose={() => setExpandedEngine(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -3533,9 +3548,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
strong
|
strong
|
||||||
style={{ fontSize: 14, color: "#10b981" }}
|
style={{ fontSize: 14, color: "#10b981" }}
|
||||||
>
|
>
|
||||||
{mediaType === "image" && currentRecord.imageSize
|
{estimatedVideoCredits}
|
||||||
? getImageCreditsFromCimage(currentRecord.imageSize) || estimatedVideoCredits
|
|
||||||
: calcVideoCredits(videoDuration, videoResolution)}
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Typography.Text style={{ color: "#cbd5e1", fontSize: 18 }}>
|
<Typography.Text style={{ color: "#cbd5e1", fontSize: 18 }}>
|
||||||
@@ -3551,7 +3564,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
strong
|
strong
|
||||||
style={{ fontSize: 16, color: "#1a1a2e" }}
|
style={{ fontSize: 16, color: "#1a1a2e" }}
|
||||||
>
|
>
|
||||||
{(lastTextCredits + (mediaType === "image" ? getImageCreditsFromCimage(currentRecord.imageSize) : calcVideoCredits(videoDuration, videoResolution))).toFixed(2)}
|
{(lastTextCredits + estimatedVideoCredits).toFixed(2)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3587,6 +3600,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
loading={recordStates[currentRecord.id] === "generating"}
|
loading={recordStates[currentRecord.id] === "generating"}
|
||||||
disabled={
|
disabled={
|
||||||
!canAffordVideo ||
|
!canAffordVideo ||
|
||||||
|
currentRecord.canGenerate === false ||
|
||||||
recordStates[currentRecord.id] === "generating" ||
|
recordStates[currentRecord.id] === "generating" ||
|
||||||
recordStates[currentRecord.id] === "done"
|
recordStates[currentRecord.id] === "done"
|
||||||
}
|
}
|
||||||
@@ -3611,7 +3625,20 @@ const GeneratePage: React.FC = () => {
|
|||||||
? "生成完成"
|
? "生成完成"
|
||||||
: recordStates[currentRecord.id] === "generating"
|
: recordStates[currentRecord.id] === "generating"
|
||||||
? "生成中..."
|
? "生成中..."
|
||||||
: `生成${mediaType === "image" ? "图片" : "视频"} (${ (mediaType === "image" ? getImageCreditsFromCimage(currentRecord.imageSize) : calcVideoCredits(videoDuration, videoResolution))}积分)`}
|
: `生成${mediaType === "image" ? "图片" : "视频"} (${(mediaType === "image"
|
||||||
|
? getImageCreditsFromCimage(
|
||||||
|
currentRecord.imageSize || selectedResolution,
|
||||||
|
currentRecord.engineId,
|
||||||
|
currentRecord.includeMediaReferences,
|
||||||
|
currentRecord.references,
|
||||||
|
)
|
||||||
|
: calcVideoCredits(
|
||||||
|
currentRecord.duration || videoDuration,
|
||||||
|
(currentRecord.resolution || videoResolution) as Resolution,
|
||||||
|
currentRecord.engineId,
|
||||||
|
currentRecord.includeMediaReferences,
|
||||||
|
currentRecord.references,
|
||||||
|
))}积分)`}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -3810,7 +3837,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
待配置
|
待生成
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -3824,7 +3851,7 @@ const GeneratePage: React.FC = () => {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{record.duration ? `${record.imageSize}` : "-"} ·{" "}
|
{record.imageSize || "-"} ·{" "}
|
||||||
{record.imageProportion || "-"} ·{" "}
|
{record.imageProportion || "-"} ·{" "}
|
||||||
{record.imagePx || "-"} ·{" "}
|
{record.imagePx || "-"} ·{" "}
|
||||||
<span className="date-display" translate="no">
|
<span className="date-display" translate="no">
|
||||||
@@ -4429,279 +4456,54 @@ const GeneratePage: React.FC = () => {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 将参数选择和视频生成按钮放到视频视频框 */}
|
{/* 待生成记录只能读取提词阶段冻结的配置 */}
|
||||||
{status === "prompt_optimized" && (
|
{status === "prompt_optimized" && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
marginTop: 16,
|
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 0,
|
inset: 0,
|
||||||
left: 0,
|
padding: 16,
|
||||||
width: "100%",
|
borderRadius: 12,
|
||||||
height: "100%",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
|
||||||
<Select
|
|
||||||
size="small"
|
|
||||||
value={historyParams[record.id]?.engineId || record.engineId || (type === 'image' ? imageEngines[0]?.id : videoEngines[0]?.id)}
|
|
||||||
onChange={(value) => {
|
|
||||||
const options = type === 'video' ? getVideoEngineOptions(value) : null;
|
|
||||||
setHistoryParams((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[record.id]: {
|
|
||||||
...prev[record.id],
|
|
||||||
engineId: value,
|
|
||||||
aspectRatio: type === 'video' ? options!.ratios[0] as AspectRatio : prev[record.id]?.aspectRatio,
|
|
||||||
resolution: type === 'video' ? options!.resolutions[0] as Resolution : prev[record.id]?.resolution,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
options={(type === 'image' ? imageEngines : videoEngines).map((item: any) => ({ value: item.id, label: item.name }))}
|
|
||||||
style={{ minWidth: 160 }}
|
|
||||||
/>
|
|
||||||
{record.references?.length ? (
|
|
||||||
<><Switch size="small" checked={Boolean(historyParams[record.id]?.includeMediaReferences ?? record.includeMediaReferences)} onChange={(checked) => setHistoryParams((prev) => ({ ...prev, [record.id]: { ...prev[record.id], includeMediaReferences: checked, aspectRatio: prev[record.id]?.aspectRatio || '' as AspectRatio, resolution: prev[record.id]?.resolution || '' as Resolution } }))} /><Typography.Text style={{ fontSize: 12 }}>携带附件</Typography.Text></>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{type === "video" && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 12,
|
|
||||||
flexWrap: "wrap",
|
|
||||||
marginBottom: 12,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
translate="no"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 6,
|
|
||||||
padding: "5px 12px",
|
|
||||||
borderRadius: 8,
|
|
||||||
background: "#f8f9fc",
|
background: "#f8f9fc",
|
||||||
border: "1px solid #e2e8f0",
|
border: "1px solid #e2e8f0",
|
||||||
marginLeft: 12,
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography.Text
|
<div>
|
||||||
style={{ fontSize: 12, color: "#94a3b8" }}
|
<Typography.Text strong style={{ display: "block", marginBottom: 10 }}>冻结生成配置</Typography.Text>
|
||||||
>
|
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>引擎:{record.engineName || record.engineId || "配置缺失"}</Typography.Text>
|
||||||
时长
|
{type === "video" ? (
|
||||||
</Typography.Text>
|
<>
|
||||||
<Typography.Text
|
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>时长:{record.duration || "-"}秒</Typography.Text>
|
||||||
strong
|
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>比例:{record.aspectRatio || "-"}</Typography.Text>
|
||||||
style={{ fontSize: 13, color: "#6366f1" }}
|
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>分辨率:{record.resolution || "-"}</Typography.Text>
|
||||||
>
|
</>
|
||||||
{record.duration}秒
|
) : (
|
||||||
</Typography.Text>
|
<>
|
||||||
</div>
|
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>分辨率:{record.imageSize || "-"}</Typography.Text>
|
||||||
<PortalDropdown
|
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>比例:{record.imageProportion || "-"}</Typography.Text>
|
||||||
label="比例"
|
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>尺寸:{record.imagePx || "-"}</Typography.Text>
|
||||||
value={
|
</>
|
||||||
historyParams[record.id]?.aspectRatio ||
|
|
||||||
"选择比例"
|
|
||||||
}
|
|
||||||
options={getVideoEngineOptions(historyParams[record.id]?.engineId || record.engineId || videoEngines[0]?.id).ratios}
|
|
||||||
expanded={
|
|
||||||
expandedEngine === `ratio-${record.id}`
|
|
||||||
}
|
|
||||||
onToggle={() =>
|
|
||||||
setExpandedEngine(
|
|
||||||
expandedEngine === `ratio-${record.id}`
|
|
||||||
? null
|
|
||||||
: `ratio-${record.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onSelect={(v) =>
|
|
||||||
setHistoryParams((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[record.id]: {
|
|
||||||
...prev[record.id],
|
|
||||||
aspectRatio: v as AspectRatio,
|
|
||||||
resolution:
|
|
||||||
prev[record.id]?.resolution || "",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
onClose={() => setExpandedEngine(null)}
|
|
||||||
/>
|
|
||||||
<PortalDropdown
|
|
||||||
label="分辨率"
|
|
||||||
value={
|
|
||||||
historyParams[record.id]?.resolution ||
|
|
||||||
"选择分辨率"
|
|
||||||
}
|
|
||||||
options={getVideoEngineOptions(historyParams[record.id]?.engineId || record.engineId || videoEngines[0]?.id).resolutions}
|
|
||||||
expanded={
|
|
||||||
expandedEngine === `res-${record.id}`
|
|
||||||
}
|
|
||||||
onToggle={() =>
|
|
||||||
setExpandedEngine(
|
|
||||||
expandedEngine === `res-${record.id}`
|
|
||||||
? null
|
|
||||||
: `res-${record.id}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onSelect={(v) =>
|
|
||||||
setHistoryParams((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[record.id]: {
|
|
||||||
...prev[record.id],
|
|
||||||
aspectRatio:
|
|
||||||
prev[record.id]?.aspectRatio || "",
|
|
||||||
resolution: v as Resolution,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
onClose={() => setExpandedEngine(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<Typography.Text style={{ display: "block", fontSize: 12, color: "#64748b" }}>附件:{record.includeMediaReferences ? "生成时携带" : "仅用于提词或不携带"}</Typography.Text>
|
||||||
|
{record.configComplete === false && (
|
||||||
|
<Typography.Text type={record.canGenerate === false ? "danger" : "warning"} style={{ display: "block", marginTop: 8 }}>
|
||||||
|
{record.configFallbackHint || (record.canGenerate === false ? "旧版本配置不完整,请重新生成提词" : "旧版本配置缺失,提交生成时将由后端自动补齐")}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
icon={<RocketOutlined />}
|
icon={<RocketOutlined />}
|
||||||
block
|
|
||||||
loading={generating[record.id]}
|
loading={generating[record.id]}
|
||||||
disabled={
|
disabled={record.canGenerate === false}
|
||||||
type === "video" &&
|
onClick={() => handleGenerate(record.id)}
|
||||||
(!historyParams[record.id]?.aspectRatio ||
|
style={{ borderRadius: 12, fontWeight: 600, height: 48 }}
|
||||||
!historyParams[record.id]?.resolution)
|
|
||||||
}
|
|
||||||
onClick={async () => {
|
|
||||||
const params = historyParams[record.id];
|
|
||||||
// 视频类型需要检查比例和分辨率
|
|
||||||
if (type === "video") {
|
|
||||||
if (
|
|
||||||
!params?.aspectRatio ||
|
|
||||||
!params?.resolution
|
|
||||||
) {
|
|
||||||
message.error("请选择比例和分辨率");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
userCredits <
|
|
||||||
calcVideoCredits(
|
|
||||||
record.duration || 5,
|
|
||||||
params.resolution,
|
|
||||||
params.engineId || record.engineId || videoEngines[0]?.id,
|
|
||||||
Boolean(params.includeMediaReferences ?? record.includeMediaReferences),
|
|
||||||
record.references,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
message.error("积分不足,请先充值");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setGenerating((p) => ({
|
|
||||||
...p,
|
|
||||||
[record.id]: true,
|
|
||||||
}));
|
|
||||||
setRecordStates((p) => ({
|
|
||||||
...p,
|
|
||||||
[record.id]: "generating",
|
|
||||||
}));
|
|
||||||
const mediaText =
|
|
||||||
type === "video" ? "视频" : "图片";
|
|
||||||
message.loading({
|
|
||||||
content: `「${projectName}」正在生成${mediaText}...`,
|
|
||||||
duration: 0,
|
|
||||||
key: record.id,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await generateVideo(record.id, {
|
|
||||||
engineId: params?.engineId || record.engineId || (type === 'image' ? imageEngines[0]?.id : videoEngines[0]?.id),
|
|
||||||
includeMediaReferences: Boolean(params?.includeMediaReferences ?? record.includeMediaReferences),
|
|
||||||
aspectRatio: params?.aspectRatio,
|
|
||||||
resolution: params?.resolution,
|
|
||||||
imageSize: record.imageSize,
|
|
||||||
});
|
|
||||||
startPolling(record.id);
|
|
||||||
message.success({
|
|
||||||
content: `「${projectName}」${mediaText}已提交,正在生成中...`,
|
|
||||||
key: record.id,
|
|
||||||
duration: 3,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
setRecordStates((p) => ({
|
|
||||||
...p,
|
|
||||||
[record.id]: "failed",
|
|
||||||
}));
|
|
||||||
const errorMsg = error?.response?.data?.message || error?.message || `${mediaText}生成失败`;
|
|
||||||
message.error({
|
|
||||||
content: `「${projectName}」${errorMsg}`,
|
|
||||||
key: record.id,
|
|
||||||
duration: 3,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setGenerating((p) => ({
|
|
||||||
...p,
|
|
||||||
[record.id]: false,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
borderRadius: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "#fff",
|
|
||||||
height: 48,
|
|
||||||
background:
|
|
||||||
"linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)",
|
|
||||||
border: "none",
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 18,
|
|
||||||
left: 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
生成{type === "video" ? "视频" : "图片"}
|
生成{type === "video" ? "视频" : "图片"}
|
||||||
{type === "video" ? (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: "#d2d2d2",
|
|
||||||
fontSize: "14px",
|
|
||||||
marginLeft: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
(请选择上方的比例分辨率)
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
""
|
|
||||||
)}{" "}
|
|
||||||
{/* {type === "video" &&
|
|
||||||
historyParams[record.id]?.resolution
|
|
||||||
? `(${calcVideoCredits(
|
|
||||||
record.duration || 5,
|
|
||||||
historyParams[record.id].resolution as Resolution,
|
|
||||||
historyParams[record.id].engineId || record.engineId || videoEngines[0]?.id,
|
|
||||||
Boolean(historyParams[record.id].includeMediaReferences ?? record.includeMediaReferences),
|
|
||||||
record.references,
|
|
||||||
)}积分)`
|
|
||||||
: ""} */}
|
|
||||||
{type === "video" &&
|
|
||||||
historyParams[record.id]?.resolution
|
|
||||||
? `(${calcVideoCredits(
|
|
||||||
record.duration || 5,
|
|
||||||
historyParams[record.id].resolution as Resolution,
|
|
||||||
historyParams[record.id].engineId || record.engineId || videoEngines[0]?.id,
|
|
||||||
Boolean(historyParams[record.id].includeMediaReferences ?? record.includeMediaReferences),
|
|
||||||
record.references,
|
|
||||||
)}积分)`
|
|
||||||
: ""}
|
|
||||||
{type === "image" && record.imageSize
|
|
||||||
? `(${getImageCreditsFromCimage(
|
|
||||||
record.imageSize,
|
|
||||||
historyParams[record.id]?.engineId || record.engineId || imageEngines[0]?.id,
|
|
||||||
Boolean(historyParams[record.id]?.includeMediaReferences ?? record.includeMediaReferences),
|
|
||||||
record.references,
|
|
||||||
)}积分)`
|
|
||||||
: type === "image"
|
|
||||||
? `(积分)`
|
|
||||||
: ""}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -4925,73 +4727,12 @@ const GeneratePage: React.FC = () => {
|
|||||||
<Typography.Text
|
<Typography.Text
|
||||||
style={{ color: "#94a3b8", fontSize: 13 }}
|
style={{ color: "#94a3b8", fontSize: 13 }}
|
||||||
>
|
>
|
||||||
{type === "image"
|
{type === "image" ? "待生成图片" : "待生成视频"}
|
||||||
? "待生成图片"
|
|
||||||
: status === "prompt_optimized"
|
|
||||||
? "待配置视频参数"
|
|
||||||
: "待生成视频"}
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Generate buttons for history records */}
|
|
||||||
{/* {status === 'prompt_optimized' && (
|
|
||||||
<div style={{ marginTop: 16, width: '100%' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
|
|
||||||
<div translate="no" style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '5px 12px', borderRadius: 8, background: '#f8f9fc', border: '1px solid #e2e8f0' }}>
|
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>时长</Typography.Text>
|
|
||||||
<Typography.Text strong style={{ fontSize: 13, color: '#6366f1' }}>{record.duration}秒</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<PortalDropdown label="比例" value={historyParams[record.id]?.aspectRatio || '选择比例'}
|
|
||||||
options={engineOptions.ratios}
|
|
||||||
expanded={expandedEngine === `ratio-${record.id}`}
|
|
||||||
onToggle={() => setExpandedEngine(expandedEngine === `ratio-${record.id}` ? null : `ratio-${record.id}`)}
|
|
||||||
onSelect={(v) => setHistoryParams(prev => ({ ...prev, [record.id]: { ...prev[record.id], aspectRatio: v as AspectRatio, resolution: prev[record.id]?.resolution || '' } }))}
|
|
||||||
onClose={() => setExpandedEngine(null)}
|
|
||||||
/>
|
|
||||||
<PortalDropdown label="分辨率" value={historyParams[record.id]?.resolution || '选择分辨率'}
|
|
||||||
options={engineOptions.resolutions}
|
|
||||||
expanded={expandedEngine === `res-${record.id}`}
|
|
||||||
onToggle={() => setExpandedEngine(expandedEngine === `res-${record.id}` ? null : `res-${record.id}`)}
|
|
||||||
onSelect={(v) => setHistoryParams(prev => ({ ...prev, [record.id]: { ...prev[record.id], aspectRatio: prev[record.id]?.aspectRatio || '', resolution: v as Resolution } }))}
|
|
||||||
onClose={() => setExpandedEngine(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="primary" size="large" icon={<RocketOutlined />} block
|
|
||||||
loading={generating[record.id]}
|
|
||||||
disabled={!historyParams[record.id]?.aspectRatio || !historyParams[record.id]?.resolution}
|
|
||||||
onClick={async () => {
|
|
||||||
const params = historyParams[record.id];
|
|
||||||
if (!params?.aspectRatio || !params?.resolution) { message.error('请选择比例和分辨率'); return; }
|
|
||||||
if (userCredits < calcVideoCredits(record.duration || 5, params.resolution)) { message.error('积分不足,请先充值'); return; }
|
|
||||||
setGenerating((p) => ({ ...p, [record.id]: true }));
|
|
||||||
setRecordStates((p) => ({ ...p, [record.id]: 'generating' }));
|
|
||||||
message.loading({ content: `「${projectName}」正在生成视频...`, duration: 0, key: record.id });
|
|
||||||
try {
|
|
||||||
await generateVideo(record.id, { engineId: params.engineId || record.engineId || videoEngines[0]?.id, includeMediaReferences: Boolean(params.includeMediaReferences ?? record.includeMediaReferences), aspectRatio: params.aspectRatio, resolution: params.resolution, imageSize: record.imageSize });
|
|
||||||
setRecordStates((p) => ({ ...p, [record.id]: 'done' }));
|
|
||||||
message.success({ content: `「${projectName}」视频生成成功!`, key: record.id, duration: 3 });
|
|
||||||
} catch {
|
|
||||||
setRecordStates((p) => ({ ...p, [record.id]: 'failed' }));
|
|
||||||
message.error({ content: `「${projectName}」视频生成失败`, key: record.id, duration: 3 });
|
|
||||||
} finally {
|
|
||||||
setGenerating((p) => ({ ...p, [record.id]: false }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ borderRadius: 12, fontWeight: 600, height: 48, background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', border: 'none' }}>
|
|
||||||
生成视频 {historyParams[record.id]?.resolution ? `(${calcVideoCredits(record.duration || 5, historyParams[record.id].resolution)}积分)` : ''}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{statusState.isFailure && (
|
|
||||||
<div style={{ marginTop: 16 }}>
|
|
||||||
<Button type="primary" danger size="large" icon={<PlayCircleOutlined />} block
|
|
||||||
loading={generating[record.id]}
|
|
||||||
onClick={() => handleRetryGeneration(record.id)}
|
|
||||||
style={{ borderRadius: 12, fontWeight: 600, height: 48 }}>重新生成视频</Button>
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Empty,
|
Empty,
|
||||||
Input,
|
Input,
|
||||||
message,
|
message,
|
||||||
Modal,
|
|
||||||
Pagination,
|
Pagination,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
@@ -31,14 +30,14 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAppStore } from '../store/useAppStore';
|
import { useAppStore } from '../store/useAppStore';
|
||||||
import type { AspectRatio, Resolution } from '../types';
|
import { updateRecordPrompt } from '../api';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
import { copyToClipboard } from '../utils/clipboard';
|
import { copyToClipboard } from '../utils/clipboard';
|
||||||
import { resolveGenerationUiState } from '../utils/generationTaskStatus';
|
import { resolveGenerationUiState } from '../utils/generationTaskStatus';
|
||||||
|
|
||||||
const RecordsPage: React.FC = () => {
|
const RecordsPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { records, projects, fetchRecords, fetchProjects, generateVideo } = useAppStore();
|
const { records, projects, fetchRecords, fetchProjects, generateVideo, retryGeneration } = useAppStore();
|
||||||
const recordItems = records.items;
|
const recordItems = records.items;
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
@@ -48,8 +47,33 @@ const RecordsPage: React.FC = () => {
|
|||||||
const [editablePrompts, setEditablePrompts] = useState<Record<string, string>>({});
|
const [editablePrompts, setEditablePrompts] = useState<Record<string, string>>({});
|
||||||
const [editingRecordId, setEditingRecordId] = useState<string | null>(null);
|
const [editingRecordId, setEditingRecordId] = useState<string | null>(null);
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
// Generate modal for param selection
|
const submitFrozenGeneration = async (record: any) => {
|
||||||
const [genModal, setGenModal] = useState<{ recordId: string; projectName: string; ratio: AspectRatio; resolution: Resolution } | null>(null);
|
const type = record.genType || 'image';
|
||||||
|
const retry = record.canRetry === true;
|
||||||
|
if ((!retry && (record.canGenerate === false || !record.configComplete)) || (retry && !record.configComplete)) {
|
||||||
|
message.error('该记录生成配置不完整,请重新生成提词');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setGenerating((prev) => ({ ...prev, [record.id]: true }));
|
||||||
|
message.loading({ content: `「${record.projectName}」正在提交${type === 'video' ? '视频' : '图片'}生成...`, duration: 0, key: record.id });
|
||||||
|
try {
|
||||||
|
if (retry) {
|
||||||
|
await retryGeneration(record.id);
|
||||||
|
} else {
|
||||||
|
const editedPrompt = editablePrompts[record.id];
|
||||||
|
if (editedPrompt && editedPrompt !== record.optimizedPrompt) {
|
||||||
|
await updateRecordPrompt(record.id, editedPrompt);
|
||||||
|
}
|
||||||
|
await generateVideo(record.id);
|
||||||
|
}
|
||||||
|
message.success({ content: `「${record.projectName}」已提交,正在生成中...`, key: record.id, duration: 3 });
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error({ content: error?.response?.data?.detail || error?.message || '提交生成失败', key: record.id, duration: 3 });
|
||||||
|
} finally {
|
||||||
|
setGenerating((prev) => ({ ...prev, [record.id]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProjects();
|
fetchProjects();
|
||||||
@@ -64,48 +88,6 @@ const RecordsPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}, [fetchRecords, filterProject, filterStatus, currentPage, pageSize]);
|
}, [fetchRecords, filterProject, filterStatus, currentPage, pageSize]);
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
|
||||||
if (!genModal) return;
|
|
||||||
const { recordId, projectName, ratio, resolution } = genModal;
|
|
||||||
setGenerating((p) => ({ ...p, [recordId]: true }));
|
|
||||||
setGenModal(null);
|
|
||||||
message.loading({ content: `「${projectName}」正在生成视频...`, duration: 0, key: recordId });
|
|
||||||
try {
|
|
||||||
await generateVideo(recordId, { aspectRatio: ratio, resolution });
|
|
||||||
message.success({ content: `「${projectName}」视频生成成功!`, key: recordId, duration: 3 });
|
|
||||||
} catch {
|
|
||||||
message.error({ content: `「${projectName}」视频生成失败`, key: recordId, duration: 3 });
|
|
||||||
} finally {
|
|
||||||
setGenerating((p) => ({ ...p, [recordId]: false }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openGenModal = (record: any) => {
|
|
||||||
const type: any = record.genType || 'image';
|
|
||||||
|
|
||||||
// 如果是图片类型,直接生成,不需要弹窗
|
|
||||||
if (type === 'image') {
|
|
||||||
setGenerating((p) => ({ ...p, [record.id]: true }));
|
|
||||||
message.loading({ content: `「${record.projectName}」正在生成图片...`, duration: 0, key: record.id });
|
|
||||||
generateVideo(record.id, {}).then(() => {
|
|
||||||
message.success({ content: `「${record.projectName}」图片生成成功!`, key: record.id, duration: 3 });
|
|
||||||
}).catch(() => {
|
|
||||||
message.error({ content: `「${record.projectName}」图片生成失败`, key: record.id, duration: 3 });
|
|
||||||
}).finally(() => {
|
|
||||||
setGenerating((p) => ({ ...p, [record.id]: false }));
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是视频类型,显示参数选择弹窗
|
|
||||||
setGenModal({
|
|
||||||
recordId: record.id,
|
|
||||||
projectName: record.projectName,
|
|
||||||
ratio: (record.aspectRatio as AspectRatio) || '16:9',
|
|
||||||
resolution: (record.resolution as Resolution) || '720p',
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const filtered = recordItems;
|
const filtered = recordItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -222,7 +204,7 @@ const RecordsPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Meta */}
|
{/* Meta */}
|
||||||
{type === 'image' ? <span className="mobile-meta" translate="no" style={{ fontSize: 12, color: '#94a3b8', flexShrink: 0 }}>
|
{type === 'image' ? <span className="mobile-meta" translate="no" style={{ fontSize: 12, color: '#94a3b8', flexShrink: 0 }}>
|
||||||
{record.duration ? `${record.imageSize}` : '-'} · {record.imageProportion || '-'} · {record.imagePx || '-'} · <span className="date-display" translate="no">{formatDate(record.createdAt)}</span>
|
{record.imageSize || '-'} · {record.imageProportion || '-'} · {record.imagePx || '-'} · <span className="date-display" translate="no">{formatDate(record.createdAt)}</span>
|
||||||
</span> : <span className="mobile-meta" translate="no" style={{ fontSize: 12, color: '#94a3b8', flexShrink: 0 }}>
|
</span> : <span className="mobile-meta" translate="no" style={{ fontSize: 12, color: '#94a3b8', flexShrink: 0 }}>
|
||||||
{record.duration ? `${record.duration}秒` : '-'} · {record.aspectRatio || '-'} · {record.resolution || '-'} · <span className="date-display" translate="no">{formatDate(record.createdAt)}</span>
|
{record.duration ? `${record.duration}秒` : '-'} · {record.aspectRatio || '-'} · {record.resolution || '-'} · <span className="date-display" translate="no">{formatDate(record.createdAt)}</span>
|
||||||
</span>}
|
</span>}
|
||||||
@@ -233,7 +215,8 @@ const RecordsPage: React.FC = () => {
|
|||||||
{record.status === 'prompt_optimized' && (
|
{record.status === 'prompt_optimized' && (
|
||||||
<Button type="primary" size="small" icon={<PlayCircleOutlined />}
|
<Button type="primary" size="small" icon={<PlayCircleOutlined />}
|
||||||
loading={isGenerating}
|
loading={isGenerating}
|
||||||
onClick={() => openGenModal(record)}
|
disabled={!record.configComplete || record.canGenerate === false}
|
||||||
|
onClick={() => submitFrozenGeneration(record)}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||||
@@ -250,7 +233,8 @@ const RecordsPage: React.FC = () => {
|
|||||||
{uiState.isFailure && (
|
{uiState.isFailure && (
|
||||||
<Button type="primary" danger size="small" icon={<PlayCircleOutlined />}
|
<Button type="primary" danger size="small" icon={<PlayCircleOutlined />}
|
||||||
loading={isGenerating}
|
loading={isGenerating}
|
||||||
onClick={() => openGenModal(record)}
|
disabled={!record.configComplete || record.canRetry === false}
|
||||||
|
onClick={() => submitFrozenGeneration(record)}
|
||||||
style={{ borderRadius: 8 }}>
|
style={{ borderRadius: 8 }}>
|
||||||
重试
|
重试
|
||||||
</Button>
|
</Button>
|
||||||
@@ -355,7 +339,7 @@ const RecordsPage: React.FC = () => {
|
|||||||
{type === 'image' ? [
|
{type === 'image' ? [
|
||||||
{ label: '分辨率', value: record.imageSize ? `${record.imageSize} ` : '-' },
|
{ label: '分辨率', value: record.imageSize ? `${record.imageSize} ` : '-' },
|
||||||
{ label: '画面比例', value: record.imageProportion ? `${record.imageProportion} ` : '-' },
|
{ label: '画面比例', value: record.imageProportion ? `${record.imageProportion} ` : '-' },
|
||||||
{ label: '画面尺寸', value: record.imageProportion ? `${record.imageProportion} ` : '-' },
|
{ label: '画面尺寸', value: record.imagePx ? `${record.imagePx} ` : '-' },
|
||||||
{ label: '消耗积分', value: record.creditsCost ? `${record.creditsCost} ` : '-', highlight: !!record.creditsCost },
|
{ label: '消耗积分', value: record.creditsCost ? `${record.creditsCost} ` : '-', highlight: !!record.creditsCost },
|
||||||
].map((item, j) => (
|
].map((item, j) => (
|
||||||
<div key={j} style={{ flex: 1 }}>
|
<div key={j} style={{ flex: 1 }}>
|
||||||
@@ -364,8 +348,8 @@ const RecordsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)) : [
|
)) : [
|
||||||
{ label: '时长', value: record.duration ? `${record.duration} 秒` : '-' },
|
{ label: '时长', value: record.duration ? `${record.duration} 秒` : '-' },
|
||||||
{ label: '画面比例', value: record.aspectRatio || (record.status === 'prompt_optimized' ? '待选择' : '-') },
|
{ label: '画面比例', value: record.aspectRatio || (record.status === 'prompt_optimized' ? '配置缺失' : '-') },
|
||||||
{ label: '分辨率', value: record.resolution || (record.status === 'prompt_optimized' ? '待选择' : '-') },
|
{ label: '分辨率', value: record.resolution || (record.status === 'prompt_optimized' ? '配置缺失' : '-') },
|
||||||
{ label: '消耗积分', value: record.creditsCost ? `${record.creditsCost}` : '-', highlight: !!record.creditsCost },
|
{ label: '消耗积分', value: record.creditsCost ? `${record.creditsCost}` : '-', highlight: !!record.creditsCost },
|
||||||
].map((item, j) => (
|
].map((item, j) => (
|
||||||
<div key={j} style={{ flex: 1 }}>
|
<div key={j} style={{ flex: 1 }}>
|
||||||
@@ -380,13 +364,14 @@ const RecordsPage: React.FC = () => {
|
|||||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<Button type="primary" size="large" icon={<RocketOutlined />}
|
<Button type="primary" size="large" icon={<RocketOutlined />}
|
||||||
loading={isGenerating}
|
loading={isGenerating}
|
||||||
onClick={() => openGenModal(record)}
|
disabled={!record.configComplete || record.canGenerate === false}
|
||||||
|
onClick={() => submitFrozenGeneration(record)}
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 12, fontWeight: 600, height: 44,
|
borderRadius: 12, fontWeight: 600, height: 44,
|
||||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||||
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.3)',
|
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.3)',
|
||||||
}}>
|
}}>
|
||||||
{type === 'video' ? '选择参数生成视频' : '生成图片'}
|
{type === 'video' ? '生成视频' : '生成图片'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -396,7 +381,8 @@ const RecordsPage: React.FC = () => {
|
|||||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<Button type="primary" danger size="large" icon={<PlayCircleOutlined />}
|
<Button type="primary" danger size="large" icon={<PlayCircleOutlined />}
|
||||||
loading={isGenerating}
|
loading={isGenerating}
|
||||||
onClick={() => openGenModal(record)}
|
disabled={!record.configComplete || record.canRetry === false}
|
||||||
|
onClick={() => submitFrozenGeneration(record)}
|
||||||
style={{ borderRadius: 12, fontWeight: 600, height: 44 }}>
|
style={{ borderRadius: 12, fontWeight: 600, height: 44 }}>
|
||||||
重新生成{type === 'video' ? '视频' : '图片'}
|
重新生成{type === 'video' ? '视频' : '图片'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -509,46 +495,6 @@ const RecordsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Generate modal */}
|
|
||||||
<Modal
|
|
||||||
title={<Space><RocketOutlined />生成视频</Space>}
|
|
||||||
open={!!genModal}
|
|
||||||
onCancel={() => setGenModal(null)}
|
|
||||||
onOk={handleGenerate}
|
|
||||||
okText="提交生成"
|
|
||||||
cancelText="取消"
|
|
||||||
width={420}
|
|
||||||
>
|
|
||||||
{genModal && (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
|
|
||||||
{(() => {
|
|
||||||
const rec = recordItems.find(r => r.id === genModal.recordId);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
|
||||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>时长</Typography.Text>
|
|
||||||
<Typography.Text strong>{rec?.duration || 5}秒</Typography.Text>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>画面比例</Typography.Text>
|
|
||||||
<Select value={genModal.ratio} onChange={(v) => setGenModal(prev => prev ? { ...prev, ratio: v } : null)}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'].map(r => ({ value: r, label: r }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>分辨率</Typography.Text>
|
|
||||||
<Select value={genModal.resolution} onChange={(v) => setGenModal(prev => prev ? { ...prev, resolution: v } : null)}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
options={['480p', '720p', '1080p'].map(r => ({ value: r, label: r }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Project, GenerationRecord, OptimizeParams, GenerateParams, OptimizeResult, Industry, MediaReference } from '../types';
|
import type { Project, GenerationRecord, OptimizeParams, OptimizeResult, Industry, MediaReference } from '../types';
|
||||||
import * as api from '../api';
|
import * as api from '../api';
|
||||||
import { useAuthStore } from './useAuthStore';
|
import { useAuthStore } from './useAuthStore';
|
||||||
|
|
||||||
@@ -41,7 +41,8 @@ interface AppState {
|
|||||||
|
|
||||||
fetchRecords: (params?: api.GetRecordsPageParams) => Promise<void>;
|
fetchRecords: (params?: api.GetRecordsPageParams) => Promise<void>;
|
||||||
optimizePrompt: (projectId: string, params: OptimizeParams) => Promise<OptimizeResult>;
|
optimizePrompt: (projectId: string, params: OptimizeParams) => Promise<OptimizeResult>;
|
||||||
generateVideo: (recordId: string, params: GenerateParams) => Promise<GenerationRecord>;
|
generateVideo: (recordId: string) => Promise<GenerationRecord>;
|
||||||
|
retryGeneration: (recordId: string) => Promise<GenerationRecord>;
|
||||||
updateRecordReferences: (recordId: string, references: MediaReference[]) => void;
|
updateRecordReferences: (recordId: string, references: MediaReference[]) => void;
|
||||||
|
|
||||||
// 生成配置状态更新方法
|
// 生成配置状态更新方法
|
||||||
@@ -126,18 +127,36 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|||||||
try { await useAuthStore.getState().checkAuth(); } catch { /* */ }
|
try { await useAuthStore.getState().checkAuth(); } catch { /* */ }
|
||||||
|
|
||||||
const currentRecords = get().records;
|
const currentRecords = get().records;
|
||||||
|
const existingIndex = currentRecords.items.findIndex((item) => item.id === result.record.id);
|
||||||
|
const nextItems = existingIndex >= 0
|
||||||
|
? currentRecords.items.map((item) => item.id === result.record.id ? result.record : item)
|
||||||
|
: [result.record, ...currentRecords.items];
|
||||||
set({
|
set({
|
||||||
records: {
|
records: {
|
||||||
...currentRecords,
|
...currentRecords,
|
||||||
total: currentRecords.total + 1,
|
total: existingIndex >= 0 ? currentRecords.total : currentRecords.total + 1,
|
||||||
items: [result.record, ...currentRecords.items],
|
items: nextItems,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
generateVideo: async (recordId, params) => {
|
generateVideo: async (recordId) => {
|
||||||
const record = await api.generateVideo(recordId, params);
|
const record = await api.generateVideo(recordId);
|
||||||
|
try { await useAuthStore.getState().checkAuth(); } catch { /* */ }
|
||||||
|
|
||||||
|
const currentRecords = get().records;
|
||||||
|
set({
|
||||||
|
records: {
|
||||||
|
...currentRecords,
|
||||||
|
items: currentRecords.items.map((r) => (r.id === recordId ? record : r)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return record;
|
||||||
|
},
|
||||||
|
|
||||||
|
retryGeneration: async (recordId) => {
|
||||||
|
const record = await api.retryGeneration(recordId);
|
||||||
try { await useAuthStore.getState().checkAuth(); } catch { /* */ }
|
try { await useAuthStore.getState().checkAuth(); } catch { /* */ }
|
||||||
|
|
||||||
const currentRecords = get().records;
|
const currentRecords = get().records;
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export interface GenerationRecord {
|
|||||||
originalPrompt: string;
|
originalPrompt: string;
|
||||||
optimizedPrompt?: string;
|
optimizedPrompt?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
genType?: number;
|
genType?: 'video' | 'image';
|
||||||
aspectRatio?: AspectRatio;
|
aspectRatio?: AspectRatio;
|
||||||
resolution?: Resolution;
|
resolution?: Resolution;
|
||||||
status: GenerationStatus;
|
status: GenerationStatus;
|
||||||
@@ -197,38 +197,37 @@ export interface GenerationRecord {
|
|||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
generatedAt?: string;
|
generatedAt?: string;
|
||||||
imageSize: string;
|
imageSize?: string;
|
||||||
imageProportion: string;
|
imageProportion?: string;
|
||||||
imagePx: string;
|
imagePx?: string;
|
||||||
imageUrl: string;
|
imageUrl?: string;
|
||||||
engineId?: string;
|
engineId?: string;
|
||||||
engineName?: string;
|
engineName?: string;
|
||||||
engineSnapshot?: Record<string, any>;
|
engineSnapshot?: Record<string, any>;
|
||||||
includeMediaReferences?: boolean;
|
includeMediaReferences?: boolean;
|
||||||
|
configComplete?: boolean;
|
||||||
|
configRecoverable?: boolean;
|
||||||
|
configFallbackHint?: string;
|
||||||
|
canGenerate?: boolean;
|
||||||
|
canRetry?: boolean;
|
||||||
|
shouldPoll?: boolean;
|
||||||
|
clientStatus?: 'prompt_processing' | 'ready' | 'generating' | 'success' | 'failure';
|
||||||
|
operationPhase?: 'prompt' | 'resource';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OptimizeParams {
|
export interface OptimizeParams {
|
||||||
prompt: string;
|
prompt: string;
|
||||||
duration: number;
|
duration?: number;
|
||||||
genType?: any;
|
genType: 'video' | 'image';
|
||||||
|
engineId: string;
|
||||||
|
includeMediaReferences?: boolean;
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
aspectRatio?: string;
|
aspectRatio?: string;
|
||||||
references?: MediaReference[];
|
references?: MediaReference[];
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
image_size?: any;
|
image_size?: string;
|
||||||
image_proportion?: any;
|
image_proportion?: string;
|
||||||
image_px?: any;
|
image_px?: string;
|
||||||
video_duration?: number;
|
|
||||||
video_ratio?: string;
|
|
||||||
video_resolution?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GenerateParams {
|
|
||||||
engineId?: string;
|
|
||||||
includeMediaReferences?: boolean;
|
|
||||||
aspectRatio?: AspectRatio;
|
|
||||||
resolution?: Resolution;
|
|
||||||
imageSize?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModuleGenerationFlowVersion = 'v1' | 'v2';
|
export type ModuleGenerationFlowVersion = 'v1' | 'v2';
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ export interface GenerationStatusLike {
|
|||||||
status?: string | null;
|
status?: string | null;
|
||||||
displayStatus?: string | null;
|
displayStatus?: string | null;
|
||||||
pipelineStage?: string | null;
|
pipelineStage?: string | null;
|
||||||
|
clientStatus?: string | null;
|
||||||
|
shouldPoll?: boolean | null;
|
||||||
|
canGenerate?: boolean | null;
|
||||||
|
canRetry?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GenerationUiColor = 'default' | 'processing' | 'warning' | 'success' | 'error' | 'blue' | 'orange' | 'purple';
|
export type GenerationUiColor = 'default' | 'processing' | 'warning' | 'success' | 'error' | 'blue' | 'orange' | 'purple';
|
||||||
@@ -17,42 +21,34 @@ export interface GenerationUiState {
|
|||||||
isSuccess: boolean;
|
isSuccess: boolean;
|
||||||
isFailure: boolean;
|
isFailure: boolean;
|
||||||
isTerminal: boolean;
|
isTerminal: boolean;
|
||||||
|
canGenerate: boolean;
|
||||||
|
canRetry: boolean;
|
||||||
|
shouldPoll: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVE_STATUS_KEYS = new Set([
|
const CLIENT_STATUS_MAP: Record<string, string> = {
|
||||||
'pending',
|
prompt_processing: 'optimizing',
|
||||||
'optimizing',
|
ready: 'prompt_optimized',
|
||||||
'prompt_optimized',
|
generating: 'generating',
|
||||||
'generating',
|
success: 'completed',
|
||||||
]);
|
failure: 'failed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTIVE_STATUS_KEYS = new Set(['pending', 'optimizing', 'generating']);
|
||||||
const ACTIVE_PIPELINE_STAGES = new Set([
|
const ACTIVE_PIPELINE_STAGES = new Set([
|
||||||
'queued',
|
'queued', 'preparing', 'creating_provider_task', 'provider_result_staged',
|
||||||
'preparing',
|
'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading',
|
||||||
'creating_provider_task',
|
'retry_waiting', 'recovery_inconsistent', 'upscale_queued', 'upscale_processing',
|
||||||
'provider_result_staged',
|
'upscale_polling', 'upscale_downloading', 'upscale_finalizing', 'upscale_retry_waiting',
|
||||||
'waiting_remote',
|
|
||||||
'polling',
|
|
||||||
'result_ready',
|
|
||||||
'download_queued',
|
|
||||||
'downloading',
|
|
||||||
'retry_waiting',
|
|
||||||
'upscale_queued',
|
|
||||||
'upscale_processing',
|
|
||||||
'upscale_polling',
|
|
||||||
'upscale_downloading',
|
|
||||||
'upscale_finalizing',
|
|
||||||
'upscale_retry_waiting',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const SUCCESS_KEYS = new Set(['completed', 'done']);
|
const SUCCESS_KEYS = new Set(['completed', 'done']);
|
||||||
const FAILURE_KEYS = new Set(['failed', 'timeout', 'download_failed', 'upscale_failed']);
|
const FAILURE_KEYS = new Set(['failed', 'timeout', 'download_failed', 'upscale_failed']);
|
||||||
const TERMINAL_KEYS = new Set([...SUCCESS_KEYS, ...FAILURE_KEYS, 'deleted']);
|
const TERMINAL_KEYS = new Set([...SUCCESS_KEYS, ...FAILURE_KEYS, 'deleted']);
|
||||||
|
|
||||||
const LABELS: Record<string, string> = {
|
const LABELS: Record<string, string> = {
|
||||||
pending: '生成中',
|
pending: '待处理',
|
||||||
optimizing: '生成中',
|
optimizing: '提词处理中',
|
||||||
prompt_optimized: '生成中',
|
prompt_optimized: '待生成',
|
||||||
generating: '生成中',
|
generating: '生成中',
|
||||||
queued: '生成中',
|
queued: '生成中',
|
||||||
preparing: '生成中',
|
preparing: '生成中',
|
||||||
@@ -64,15 +60,16 @@ const LABELS: Record<string, string> = {
|
|||||||
download_queued: '生成中',
|
download_queued: '生成中',
|
||||||
downloading: '生成中',
|
downloading: '生成中',
|
||||||
retry_waiting: '生成中',
|
retry_waiting: '生成中',
|
||||||
|
recovery_inconsistent: '生成中',
|
||||||
upscale_queued: '生成中',
|
upscale_queued: '生成中',
|
||||||
upscale_processing: '生成中',
|
upscale_processing: '生成中',
|
||||||
upscale_polling: '生成中',
|
upscale_polling: '生成中',
|
||||||
upscale_downloading: '生成中',
|
upscale_downloading: '生成中',
|
||||||
upscale_finalizing: '生成中',
|
upscale_finalizing: '生成中',
|
||||||
upscale_retry_waiting: '生成中',
|
upscale_retry_waiting: '生成中',
|
||||||
completed: '已完成',
|
completed: '成功',
|
||||||
done: '已完成',
|
done: '成功',
|
||||||
timeout: '生成超时',
|
timeout: '失败',
|
||||||
download_failed: '失败',
|
download_failed: '失败',
|
||||||
upscale_failed: '失败',
|
upscale_failed: '失败',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
@@ -80,33 +77,15 @@ const LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const COLOR_MAP: Record<string, GenerationUiColor> = {
|
const COLOR_MAP: Record<string, GenerationUiColor> = {
|
||||||
pending: 'default',
|
pending: 'default', optimizing: 'processing', prompt_optimized: 'blue', generating: 'processing',
|
||||||
optimizing: 'processing',
|
queued: 'processing', preparing: 'processing', creating_provider_task: 'processing',
|
||||||
prompt_optimized: 'processing',
|
provider_result_staged: 'processing', waiting_remote: 'processing', polling: 'processing',
|
||||||
generating: 'warning',
|
result_ready: 'processing', download_queued: 'processing', downloading: 'processing',
|
||||||
queued: 'processing',
|
retry_waiting: 'orange', recovery_inconsistent: 'orange', upscale_queued: 'purple',
|
||||||
preparing: 'processing',
|
upscale_processing: 'purple', upscale_polling: 'purple', upscale_downloading: 'purple',
|
||||||
creating_provider_task: 'processing',
|
upscale_finalizing: 'purple', upscale_retry_waiting: 'orange', completed: 'success',
|
||||||
provider_result_staged: 'processing',
|
done: 'success', failed: 'error', timeout: 'error', download_failed: 'error',
|
||||||
waiting_remote: 'processing',
|
upscale_failed: 'error', deleted: 'default',
|
||||||
polling: 'processing',
|
|
||||||
result_ready: 'processing',
|
|
||||||
download_queued: 'processing',
|
|
||||||
downloading: 'processing',
|
|
||||||
retry_waiting: 'orange',
|
|
||||||
upscale_queued: 'purple',
|
|
||||||
upscale_processing: 'purple',
|
|
||||||
upscale_polling: 'purple',
|
|
||||||
upscale_downloading: 'purple',
|
|
||||||
upscale_finalizing: 'purple',
|
|
||||||
upscale_retry_waiting: 'orange',
|
|
||||||
completed: 'success',
|
|
||||||
done: 'success',
|
|
||||||
failed: 'error',
|
|
||||||
timeout: 'error',
|
|
||||||
download_failed: 'error',
|
|
||||||
upscale_failed: 'error',
|
|
||||||
deleted: 'default',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
|
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
|
||||||
@@ -121,45 +100,28 @@ export const getGenerationStatusColor = (key?: string | null): GenerationUiColor
|
|||||||
return COLOR_MAP[normalized] || 'default';
|
return COLOR_MAP[normalized] || 'default';
|
||||||
};
|
};
|
||||||
|
|
||||||
const firstMatching = (values: string[], keys: Set<string>): string => (
|
|
||||||
values.find((value) => keys.has(value)) || ''
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resolveGenerationUiState = (value: GenerationStatusLike): GenerationUiState => {
|
export const resolveGenerationUiState = (value: GenerationStatusLike): GenerationUiState => {
|
||||||
const status = normalize(value.status);
|
const status = normalize(value.status);
|
||||||
const displayStatus = normalize(value.displayStatus);
|
const displayStatus = normalize(value.displayStatus);
|
||||||
const pipelineStage = normalize(value.pipelineStage);
|
const pipelineStage = normalize(value.pipelineStage);
|
||||||
const values = [displayStatus, status, pipelineStage].filter(Boolean);
|
const clientStatus = normalize(value.clientStatus);
|
||||||
|
const mappedClientStatus = CLIENT_STATUS_MAP[clientStatus] || '';
|
||||||
|
|
||||||
const failureKey = FAILURE_KEYS.has(pipelineStage)
|
let effectiveKey = mappedClientStatus || displayStatus || status || pipelineStage || 'pending';
|
||||||
? pipelineStage
|
if (FAILURE_KEYS.has(pipelineStage) || FAILURE_KEYS.has(status) || FAILURE_KEYS.has(displayStatus)) {
|
||||||
: firstMatching([displayStatus, status], FAILURE_KEYS);
|
effectiveKey = FAILURE_KEYS.has(pipelineStage) ? pipelineStage : (FAILURE_KEYS.has(status) ? status : displayStatus);
|
||||||
const deletedKey = firstMatching(values, new Set(['deleted']));
|
} else if (SUCCESS_KEYS.has(status) || SUCCESS_KEYS.has(displayStatus)) {
|
||||||
const successKey = firstMatching(values, SUCCESS_KEYS);
|
effectiveKey = SUCCESS_KEYS.has(status) ? status : displayStatus;
|
||||||
|
} else if (!mappedClientStatus && pipelineStage && ACTIVE_PIPELINE_STAGES.has(pipelineStage)) {
|
||||||
let effectiveKey = '';
|
|
||||||
if (failureKey) {
|
|
||||||
effectiveKey = failureKey;
|
|
||||||
} else if (deletedKey) {
|
|
||||||
effectiveKey = deletedKey;
|
|
||||||
} else if (ACTIVE_PIPELINE_STAGES.has(pipelineStage)) {
|
|
||||||
effectiveKey = pipelineStage;
|
effectiveKey = pipelineStage;
|
||||||
} else if (successKey) {
|
|
||||||
effectiveKey = successKey;
|
|
||||||
} else if (pipelineStage) {
|
|
||||||
effectiveKey = pipelineStage;
|
|
||||||
} else {
|
|
||||||
effectiveKey = displayStatus || status || 'pending';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFailure = FAILURE_KEYS.has(effectiveKey);
|
const isFailure = FAILURE_KEYS.has(effectiveKey);
|
||||||
const isSuccess = SUCCESS_KEYS.has(effectiveKey);
|
const isSuccess = SUCCESS_KEYS.has(effectiveKey);
|
||||||
const isActive = !isFailure && !isSuccess && effectiveKey !== 'deleted' && (
|
const shouldPoll = typeof value.shouldPoll === 'boolean'
|
||||||
ACTIVE_PIPELINE_STAGES.has(pipelineStage)
|
? value.shouldPoll
|
||||||
|| ACTIVE_STATUS_KEYS.has(displayStatus)
|
: (!isFailure && !isSuccess && (ACTIVE_STATUS_KEYS.has(status) || ACTIVE_PIPELINE_STAGES.has(pipelineStage)));
|
||||||
|| ACTIVE_STATUS_KEYS.has(status)
|
const isActive = shouldPoll;
|
||||||
|| ACTIVE_PIPELINE_STAGES.has(effectiveKey)
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
@@ -172,6 +134,9 @@ export const resolveGenerationUiState = (value: GenerationStatusLike): Generatio
|
|||||||
isSuccess,
|
isSuccess,
|
||||||
isFailure,
|
isFailure,
|
||||||
isTerminal: TERMINAL_KEYS.has(effectiveKey),
|
isTerminal: TERMINAL_KEYS.has(effectiveKey),
|
||||||
|
canGenerate: typeof value.canGenerate === 'boolean' ? value.canGenerate : status === 'prompt_optimized',
|
||||||
|
canRetry: typeof value.canRetry === 'boolean' ? value.canRetry : isFailure,
|
||||||
|
shouldPoll,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user