解决冲突

This commit is contained in:
Lrd
2026-07-02 12:00:25 +08:00
21 changed files with 2767 additions and 272 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DveJ8Oia.js"></script>
<script type="module" crossorigin src="/assets/index-DfKYC1kL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
@@ -20,6 +20,8 @@ interface VideoEngine {
maxDuration: number;
maxImageCount: number;
maxVideoCount: number;
supportsFirstLastFrame: boolean;
supportsUniversalReference: boolean;
isActive: boolean;
priority: number;
}
@@ -72,6 +74,8 @@ const AdminVideoEngines: React.FC = () => {
max_duration: values.maxDuration ?? 15,
max_image_count: values.maxImageCount ?? 2,
max_video_count: values.maxVideoCount ?? 0,
supports_first_last_frame: values.supportsFirstLastFrame ?? false,
supports_universal_reference: values.supportsUniversalReference ?? true,
is_active: values.isActive ?? true,
priority: values.priority ?? 0,
};
@@ -112,6 +116,8 @@ const AdminVideoEngines: React.FC = () => {
maxDuration: 15,
maxImageCount: 2,
maxVideoCount: 0,
supportsFirstLastFrame: false,
supportsUniversalReference: true,
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
supportedResolutions: ['480p', '720p', '1080p'],
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
@@ -159,6 +165,14 @@ const AdminVideoEngines: React.FC = () => {
title: '最大视频', dataIndex: 'maxVideoCount', width: 100,
render: (v: number) => <Tag color="cyan">{v} </Tag>,
},
{
title: '首尾帧', dataIndex: 'supportsFirstLastFrame', width: 90,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
},
{
title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100,
render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
},
{
title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
@@ -196,7 +210,7 @@ const AdminVideoEngines: React.FC = () => {
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 900 }}
scroll={{ x: 1100 }}
/>
</Card>
@@ -263,7 +277,18 @@ const AdminVideoEngines: React.FC = () => {
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级">
<Form.Item name="supportsFirstLastFrame" label="首尾帧模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
</Form.Item>
<Form.Item name="supportsUniversalReference" label="全能参考模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
</Form.Item>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
<Switch />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
<Select size="large" options={[
{ value: 0, label: '0 (默认)' },
{ value: 1, label: '1' },
@@ -273,9 +298,6 @@ const AdminVideoEngines: React.FC = () => {
{ value: 10, label: '10 (最高)' },
]} />
</Form.Item>
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
<Switch />
</Form.Item>
</div>
</Form>
</Modal>
@@ -0,0 +1,46 @@
"""d4e5f6a7b8c9 - 视频引擎增加首尾帧和全能参考支持字段
Revision ID: d4e5f6a7b8c9
Revises: 6idufv2q1c
Create Date: 2026-07-02 00:00:00.000000
该文件包含 2026-07-02 的数据库迁移内容:
1. 视频引擎表增加 supports_first_last_frame 字段(是否支持首尾帧模式)
2. 视频引擎表增加 supports_universal_reference 字段(是否支持全能参考模式)
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'd4e5f6a7b8c9'
down_revision: Union[str, None] = 'e2618c38eae5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'video_engines',
sa.Column(
'supports_first_last_frame',
sa.Boolean(),
server_default=sa.text('false'),
nullable=False,
),
)
op.add_column(
'video_engines',
sa.Column(
'supports_universal_reference',
sa.Boolean(),
server_default=sa.text('true'),
nullable=False,
),
)
def downgrade() -> None:
op.drop_column('video_engines', 'supports_universal_reference')
op.drop_column('video_engines', 'supports_first_last_frame')
+52
View File
@@ -9,6 +9,8 @@ from app.models.chat_generation_task import ChatGenerationTask
from app.models.user import User
from app.schemas.generation_ai import (
GenerationAIEngineOptionsOut,
GenerationAIHistoryBatchDeleteOut,
GenerationAIHistoryBatchDeleteRequest,
GenerationAIHistoryDayItemsOut,
GenerationAIHistoryGroupedOut,
GenerationAIRetryOut,
@@ -31,6 +33,7 @@ from app.services.generation_billing_service import (
charge_generation_media_by_params,
get_next_credit_attempt_no,
)
from app.services.generation_history_delete_service import batch_delete_generation_history_items
from app.services.generation_log_service import log_task_event
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.resource_capacity_service import assert_user_resource_capacity_available
@@ -87,6 +90,8 @@ router = APIRouter(
"supported_durations": [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
"max_duration": 15,
"priority": 10,
"supports_first_last_frame": False,
"supports_universal_reference": False,
}
],
}
@@ -332,6 +337,53 @@ async def list_history_grouped_days(
)
@router.delete(
"/history/batch",
response_model=GenerationAIHistoryBatchDeleteOut,
summary="批量删除素材云历史记录",
description=(
"按 history_source 批量软删除素材云历史记录,单次最多30条。"
"generation_record 和 chat_task 入参 ids 为对应记录ID,且只有生成完成后才能删除;"
"hot_opening_replicate 入参 ids 为 module_project_id"
"shot_replicate 入参 ids 为 shot_segment_id。"
"爆款开头复刻和拆镜复刻会联动软删 ModuleGenerationProject、ModuleGenerationStep、关联 ChatGenerationTask 和 generated_resources"
"拆镜复刻还会联动软删 ShotReplicateSegment。"
"如果存在生成中、轮询中、下载中等任务,接口直接拦截,不做失败标记、不退款。"
),
responses={
200: {
"description": "批量软删除成功",
},
400: {
"description": "参数错误,例如 history_source 不支持、ids 为空、超过30条或重复",
},
401: {
"description": "未登录或 Token 无效",
},
404: {
"description": "部分 ID 不存在、不属于当前用户或已删除",
},
409: {
"description": "存在未完成或生成中的记录,当前不能删除",
},
},
)
async def batch_delete_history_items(
req: GenerationAIHistoryBatchDeleteRequest = Body(
...,
description="素材云历史批量删除参数。不同 history_source 对应不同 ID 语义,详见字段说明",
),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await batch_delete_generation_history_items(
db=db,
current_user=current_user,
history_source=req.history_source,
ids=req.ids,
)
@router.get(
"/history/{generated_date}",
response_model=GenerationAIHistoryDayItemsOut,
@@ -51,5 +51,7 @@ async def list_active_engines(
"supported_durations": durations,
"max_image_count": e.max_image_count,
"max_video_count": e.max_video_count,
"supports_first_last_frame": e.supports_first_last_frame,
"supports_universal_reference": e.supports_universal_reference,
})
return {"items": items}
@@ -118,3 +118,6 @@ def is_generation_history_module_source(source: GenerationHistorySourceEnum) ->
"""判断当前来源是否需要回填模块项目信息。"""
return source in GENERATION_HISTORY_MODULE_SOURCES
MAX_BATCH_DELETE_COUNT = 30
+2
View File
@@ -19,6 +19,8 @@ class VideoEngine(Base, TimestampMixin):
max_duration: Mapped[int] = mapped_column(Integer, default=15)
max_image_count: Mapped[int] = mapped_column(Integer, default=2)
max_video_count: Mapped[int] = mapped_column(Integer, default=0)
supports_first_last_frame: Mapped[bool] = mapped_column(Boolean, default=False)
supports_universal_reference: Mapped[bool] = mapped_column(Boolean, default=True)
generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
query_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+95 -1
View File
@@ -1,8 +1,9 @@
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.schemas.common import NaiveDatetimeOptional
from app.enums.generation_history import normalize_generation_history_source
class GenerationAIReference(BaseModel):
@@ -192,6 +193,8 @@ class GenerationAIVideoEngineOptionOut(BaseModel):
priority: int = Field(0, description="引擎优先级,数值越大越优先")
max_image_count: int | None = Field(None, description="最大图片数量")
max_video_count: int | None = Field(None, description="最大视频数量")
supports_first_last_frame: bool = Field(False, description="是否支持首帧和最后一帧")
supports_universal_reference: bool = Field(False, description="是否支持通用参考")
class GenerationAIEngineGroupOut(BaseModel):
@@ -245,6 +248,8 @@ class GenerationAIEngineOptionsOut(BaseModel):
"priority": 10,
"max_image_count": 2,
"max_video_count": 0,
"supports_first_last_frame": False,
"supports_universal_reference": False,
}
],
}
@@ -492,6 +497,95 @@ class GenerationAITaskDeleteOut(BaseModel):
freed_size_bytes: int = Field(0, description="本次软删联动释放的有效资源空间字节数")
class GenerationAIHistoryBatchDeleteRequest(BaseModel):
"""素材云历史批量删除请求体。"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"history_source": "shot_replicate",
"ids": ["shot_segment_id_1", "shot_segment_id_2"],
}
}
)
history_source: str = Field(
...,
description=(
"素材云历史来源。"
"generation_record=项目生成,chat_task=AI创作,"
"hot_opening_replicate=爆款开头复刻,shot_replicate=拆镜复刻。"
"注意:项目生成/AI创作传记录ID;爆款开头复刻传 module_project_id;拆镜复刻传 shot_segment_id。"
),
examples=["shot_replicate"],
)
ids: list[str] = Field(
...,
min_length=1,
max_length=30,
description="需要删除的ID数组,最多30个;不允许重复或空字符串",
examples=[["shot_segment_id_1", "shot_segment_id_2"]],
)
@field_validator("history_source")
@classmethod
def validate_history_source(cls, value: str) -> str:
try:
return normalize_generation_history_source(value).value
except ValueError as exc:
raise ValueError("history_source 不支持") from exc
@field_validator("ids")
@classmethod
def validate_ids(cls, values: list[str]) -> list[str]:
normalized = [str(item).strip() for item in values if str(item or "").strip()]
if not normalized:
raise ValueError("ids 不能为空")
if len(normalized) > 30:
raise ValueError("单次最多删除30条记录")
if len(normalized) != len(set(normalized)):
raise ValueError("ids 不允许重复")
return normalized
class GenerationAIHistoryBatchDeleteOut(BaseModel):
"""素材云历史批量删除响应体。"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"message": "删除成功",
"history_source": "shot_replicate",
"history_source_label": "拆镜复刻",
"requested_count": 2,
"deleted_count": 2,
"requested_ids": ["shot_segment_id_1", "shot_segment_id_2"],
"deleted_ids": ["shot_segment_id_1", "shot_segment_id_2"],
"generation_record_ids": [],
"chat_task_ids": ["chat_task_id_1", "chat_task_id_2"],
"module_project_ids": ["module_project_id_1", "module_project_id_2"],
"shot_segment_ids": ["shot_segment_id_1", "shot_segment_id_2"],
"deleted": True,
"freed_size_bytes": 123456,
}
}
)
message: str = Field(..., description="操作结果提示信息")
history_source: str = Field(..., description="素材云历史来源")
history_source_label: str | None = Field(None, description="素材云历史来源中文名称")
requested_count: int = Field(..., description="请求删除数量")
deleted_count: int = Field(..., description="实际删除数量")
requested_ids: list[str] = Field(default_factory=list, description="请求删除的原始ID列表")
deleted_ids: list[str] = Field(default_factory=list, description="已删除的原始ID列表")
generation_record_ids: list[str] = Field(default_factory=list, description="联动软删除的 GenerationRecord ID")
chat_task_ids: list[str] = Field(default_factory=list, description="联动软删除的 ChatGenerationTask ID")
module_project_ids: list[str] = Field(default_factory=list, description="联动软删除的 ModuleGenerationProject ID")
shot_segment_ids: list[str] = Field(default_factory=list, description="联动软删除的 ShotReplicateSegment ID")
deleted: bool = Field(..., description="是否已完成软删除")
freed_size_bytes: int = Field(0, description="本次软删联动释放的有效资源空间字节数")
class GenerationAIRetryOut(BaseModel):
"""AI生成任务重试响应体。"""
@@ -15,6 +15,8 @@ class VideoEngineCreate(BaseModel):
max_duration: int = Field(default=15)
max_image_count: int = Field(default=2)
max_video_count: int = Field(default=0)
supports_first_last_frame: bool = Field(default=False, description="是否支持首尾帧模式")
supports_universal_reference: bool = Field(default=True, description="是否支持全能参考模式")
generate_url: str = Field(default="", max_length=512)
query_url: str = Field(default="", max_length=512)
is_active: bool = True
@@ -37,6 +39,8 @@ class VideoEnginePublic(BaseModel):
supported_durations: list[int] = []
max_image_count: int = 2
max_video_count: int = 0
supports_first_last_frame: bool = False
supports_universal_reference: bool = True
class VideoEngineListResponse(BaseModel):
@@ -203,6 +203,8 @@ async def list_generation_ai_engine_options(db: AsyncSession) -> GenerationAIEng
priority=engine.priority or 0,
max_image_count=engine.max_image_count,
max_video_count=engine.max_video_count,
supports_first_last_frame=engine.supports_first_last_frame,
supports_universal_reference=engine.supports_universal_reference,
)
for engine in video_result.scalars().all()
]
@@ -0,0 +1,514 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Iterable, Sequence
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.common import ModuleEventTypeEnum
from app.enums.generation_history import (
GenerationHistorySourceEnum,
get_generation_history_source_label,
normalize_generation_history_source,
MAX_BATCH_DELETE_COUNT,
)
from app.enums.generation_task import ChatGenerationTaskStatus, GenerationMode
from app.enums.shot_replicate import ShotSegmentAnalysisStatusEnum, ShotSegmentReplicateStatusEnum, ShotSplitStatusEnum
from app.models.chat_generation_task import ChatGenerationTask
from app.models.generation_record import GenerationRecord
from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.user import User
from app.schemas.generation_ai import GenerationAIHistoryBatchDeleteOut
from app.services.module_generation_flow_base_service import is_active_chat_generation_task
from app.services.module_generation_log_service import log_module_event_file
# from app.services.operation_log import log_operation
from app.services.resource_accounting_service import (
SOURCE_MODEL_CHAT_TASK,
SOURCE_MODEL_GENERATION_RECORD,
SOURCE_MODEL_SHOT_SEGMENT,
soft_delete_generation_record_resources,
soft_delete_resources_by_source,
)
COMPLETED_STATUS = ChatGenerationTaskStatus.COMPLETED.value
_ACTIVE_SPLIT_STATUSES = {
ShotSplitStatusEnum.PENDING.value,
ShotSplitStatusEnum.PROCESSING.value,
ShotSplitStatusEnum.RETRY_WAITING.value,
}
_ACTIVE_SEGMENT_ANALYSIS_STATUSES = {
ShotSegmentAnalysisStatusEnum.PENDING.value,
ShotSegmentAnalysisStatusEnum.PROCESSING.value,
}
_ACTIVE_SEGMENT_REPLICATE_STATUSES = {
ShotSegmentReplicateStatusEnum.PROCESSING.value,
}
def _normalize_ids(ids: Sequence[str] | Iterable[str]) -> list[str]:
normalized = [str(item).strip() for item in ids if str(item or "").strip()]
if not normalized:
raise HTTPException(status_code=400, detail="ids 不能为空")
if len(normalized) > MAX_BATCH_DELETE_COUNT:
raise HTTPException(status_code=400, detail=f"单次最多删除 {MAX_BATCH_DELETE_COUNT} 条记录")
if len(normalized) != len(set(normalized)):
raise HTTPException(status_code=400, detail="ids 不允许重复")
return normalized
def _missing_ids(request_ids: list[str], actual_ids: Iterable[str]) -> list[str]:
actual_set = {str(item) for item in actual_ids if item}
return [item for item in request_ids if item not in actual_set]
def _raise_missing_if_any(*, ids: list[str], found_ids: Iterable[str], message: str) -> None:
missing = _missing_ids(ids, found_ids)
if missing:
raise HTTPException(
status_code=404,
detail={
"message": message,
"missing_ids": missing,
"missing_count": len(missing),
},
)
def _raise_invalid_if_any(*, invalid_ids: list[str], message: str, status_code: int = 409) -> None:
if invalid_ids:
raise HTTPException(
status_code=status_code,
detail={
"message": message,
"invalid_ids": invalid_ids,
"invalid_count": len(invalid_ids),
},
)
def _task_id_list(steps: list[ModuleGenerationStep]) -> list[str]:
return list(dict.fromkeys(step.chat_task_id for step in steps if step.chat_task_id))
async def _load_chat_tasks_for_steps(
db: AsyncSession,
steps: list[ModuleGenerationStep],
) -> dict[str, ChatGenerationTask]:
task_ids = _task_id_list(steps)
if not task_ids:
return {}
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id.in_(task_ids),
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
)
return {task.id: task for task in result.scalars().all()}
def _assert_no_active_chat_tasks(tasks: Iterable[ChatGenerationTask]) -> None:
active_task_ids = [task.id for task in tasks if is_active_chat_generation_task(task)]
if active_task_ids:
raise HTTPException(
status_code=409,
detail={
"message": "当前存在生成中任务,请等待生成完成或失败后再操作",
"active_count": len(active_task_ids),
},
)
# async def _log_history_batch_delete(
# db: AsyncSession,
# *,
# current_user: User,
# result: GenerationAIHistoryBatchDeleteOut,
# ) -> None:
# detail = {
# "history_source": result.history_source,
# "history_source_label": result.history_source_label,
# "requested_count": result.requested_count,
# "deleted_count": result.deleted_count,
# "requested_ids": result.requested_ids,
# "deleted_ids": result.deleted_ids,
# "generation_record_ids": result.generation_record_ids,
# "chat_task_ids": result.chat_task_ids,
# "module_project_ids": result.module_project_ids,
# "shot_segment_ids": result.shot_segment_ids,
# "freed_size_bytes": result.freed_size_bytes,
# }
# await log_operation(
# db,
# current_user.id,
# current_user.username,
# f"批量删除素材云历史-{result.history_source_label or result.history_source}",
# "DELETE",
# "/generation-ai/history/batch",
# detail=json.dumps(detail, ensure_ascii=False, default=str),
# )
def _build_out(
*,
source: GenerationHistorySourceEnum,
requested_ids: list[str],
deleted_ids: list[str],
generation_record_ids: list[str] | None = None,
chat_task_ids: list[str] | None = None,
module_project_ids: list[str] | None = None,
shot_segment_ids: list[str] | None = None,
freed_size_bytes: int = 0,
) -> GenerationAIHistoryBatchDeleteOut:
return GenerationAIHistoryBatchDeleteOut(
message="删除成功",
history_source=source.value,
history_source_label=get_generation_history_source_label(source),
requested_count=len(requested_ids),
deleted_count=len(deleted_ids),
requested_ids=requested_ids,
deleted_ids=deleted_ids,
generation_record_ids=generation_record_ids or [],
chat_task_ids=chat_task_ids or [],
module_project_ids=module_project_ids or [],
shot_segment_ids=shot_segment_ids or [],
deleted=True,
freed_size_bytes=int(freed_size_bytes or 0),
)
async def _delete_generation_records(
db: AsyncSession,
*,
current_user: User,
source: GenerationHistorySourceEnum,
ids: list[str],
deleted_at: datetime,
) -> GenerationAIHistoryBatchDeleteOut:
result = await db.execute(
select(GenerationRecord)
.where(
GenerationRecord.id.in_(ids),
GenerationRecord.user_id == current_user.id,
GenerationRecord.deleted_at.is_(None),
)
.with_for_update()
)
records = list(result.scalars().all())
_raise_missing_if_any(ids=ids, found_ids=[record.id for record in records], message="项目生成记录不存在或已删除")
invalid_ids = [
record.id
for record in records
if record.status != COMPLETED_STATUS or record.generated_at is None
]
_raise_invalid_if_any(invalid_ids=invalid_ids, message="项目生成记录只有生成完成后才能删除")
freed_size = await soft_delete_generation_record_resources(db, [record.id for record in records], deleted_at=deleted_at)
for record in records:
record.deleted_at = deleted_at
return _build_out(
source=source,
requested_ids=ids,
deleted_ids=ids,
generation_record_ids=ids,
freed_size_bytes=freed_size,
)
async def _delete_chat_tasks(
db: AsyncSession,
*,
current_user: User,
source: GenerationHistorySourceEnum,
ids: list[str],
deleted_at: datetime,
) -> GenerationAIHistoryBatchDeleteOut:
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id.in_(ids),
ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_ASYNC.value,
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
)
tasks = list(result.scalars().all())
_raise_missing_if_any(ids=ids, found_ids=[task.id for task in tasks], message="AI 创作记录不存在或已删除")
invalid_ids = [
task.id
for task in tasks
if task.status != COMPLETED_STATUS or task.generated_at is None
]
_raise_invalid_if_any(invalid_ids=invalid_ids, message="AI 创作记录只有生成完成后才能删除")
freed_size = await soft_delete_resources_by_source(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=[task.id for task in tasks],
deleted_at=deleted_at,
)
for task in tasks:
task.deleted_at = deleted_at
return _build_out(
source=source,
requested_ids=ids,
deleted_ids=ids,
chat_task_ids=ids,
freed_size_bytes=freed_size,
)
async def _load_module_projects_by_ids(
db: AsyncSession,
*,
current_user: User,
source: GenerationHistorySourceEnum,
project_ids: list[str],
) -> list[ModuleGenerationProject]:
result = await db.execute(
select(ModuleGenerationProject)
.where(
ModuleGenerationProject.id.in_(project_ids),
ModuleGenerationProject.user_id == current_user.id,
ModuleGenerationProject.module == source.value,
ModuleGenerationProject.deleted_at.is_(None),
)
.with_for_update()
)
projects = list(result.scalars().all())
_raise_missing_if_any(ids=project_ids, found_ids=[project.id for project in projects], message="模块生成项目不存在或已删除")
return projects
async def _soft_delete_module_projects(
db: AsyncSession,
*,
source: GenerationHistorySourceEnum,
projects: list[ModuleGenerationProject],
deleted_at: datetime,
) -> tuple[list[str], list[str], int]:
project_ids = list(dict.fromkeys(project.id for project in projects))
if not project_ids:
return [], [], 0
step_result = await db.execute(
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id.in_(project_ids),
ModuleGenerationStep.module == source.value,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.is_current == True,
)
.with_for_update()
)
steps = list(step_result.scalars().all())
task_map = await _load_chat_tasks_for_steps(db, steps)
_assert_no_active_chat_tasks(task_map.values())
chat_task_ids = list(task_map.keys())
freed_size = await soft_delete_resources_by_source(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=chat_task_ids,
deleted_at=deleted_at,
)
steps_by_project: dict[str, list[ModuleGenerationStep]] = {}
for step in steps:
steps_by_project.setdefault(step.project_id, []).append(step)
step.deleted_at = deleted_at
step.is_current = False
for task in task_map.values():
task.deleted_at = deleted_at
for project in projects:
project.deleted_at = deleted_at
project.final_image_url = None
project.final_video_url = None
project.final_video_cover_url = None
project.completed_at = None
project_steps = steps_by_project.get(project.id, [])
log_module_event_file(
module=source.value,
event_type=ModuleEventTypeEnum.PROJECT_DELETED.value,
project_id=project.id,
user_id=project.user_id,
message="素材云历史批量删除模块项目",
detail={
"source": "generation_history_batch_delete",
"step_ids": [step.id for step in project_steps],
"step_codes": [step.step_code for step in project_steps],
"chat_task_ids": [step.chat_task_id for step in project_steps if step.chat_task_id],
"refund_unfinished": False,
},
)
return project_ids, chat_task_ids, int(freed_size or 0)
async def _delete_hot_opening_projects(
db: AsyncSession,
*,
current_user: User,
source: GenerationHistorySourceEnum,
ids: list[str],
deleted_at: datetime,
) -> GenerationAIHistoryBatchDeleteOut:
projects = await _load_module_projects_by_ids(db, current_user=current_user, source=source, project_ids=ids)
module_project_ids, chat_task_ids, freed_size = await _soft_delete_module_projects(
db,
source=source,
projects=projects,
deleted_at=deleted_at,
)
return _build_out(
source=source,
requested_ids=ids,
deleted_ids=ids,
module_project_ids=module_project_ids,
chat_task_ids=chat_task_ids,
freed_size_bytes=freed_size,
)
def _assert_segments_not_active(segments: list[ShotReplicateSegment]) -> None:
invalid_ids = [
segment.id
for segment in segments
if segment.split_status in _ACTIVE_SPLIT_STATUSES
or segment.analysis_status in _ACTIVE_SEGMENT_ANALYSIS_STATUSES
or segment.replicate_status in _ACTIVE_SEGMENT_REPLICATE_STATUSES
]
_raise_invalid_if_any(invalid_ids=invalid_ids, message="拆镜片段仍在分割、分析或复刻处理中,暂不能删除")
async def _delete_shot_segments(
db: AsyncSession,
*,
current_user: User,
source: GenerationHistorySourceEnum,
ids: list[str],
deleted_at: datetime,
) -> GenerationAIHistoryBatchDeleteOut:
result = await db.execute(
select(ShotReplicateSegment)
.where(
ShotReplicateSegment.id.in_(ids),
ShotReplicateSegment.user_id == current_user.id,
ShotReplicateSegment.deleted_at.is_(None),
)
.with_for_update()
)
segments = list(result.scalars().all())
_raise_missing_if_any(ids=ids, found_ids=[segment.id for segment in segments], message="拆镜复刻片段不存在或已删除")
_assert_segments_not_active(segments)
missing_project_segment_ids = [segment.id for segment in segments if not segment.module_project_id]
_raise_invalid_if_any(invalid_ids=missing_project_segment_ids, message="拆镜复刻片段尚未关联复刻项目,不能按素材云历史删除")
module_project_ids = list(dict.fromkeys(segment.module_project_id for segment in segments if segment.module_project_id))
projects = await _load_module_projects_by_ids(
db,
current_user=current_user,
source=source,
project_ids=module_project_ids,
)
deleted_project_ids, chat_task_ids, project_freed_size = await _soft_delete_module_projects(
db,
source=source,
projects=projects,
deleted_at=deleted_at,
)
segment_freed_size = await soft_delete_resources_by_source(
db,
source_model=SOURCE_MODEL_SHOT_SEGMENT,
source_ids=[segment.id for segment in segments],
deleted_at=deleted_at,
)
for segment in segments:
segment.deleted_at = deleted_at
return _build_out(
source=source,
requested_ids=ids,
deleted_ids=ids,
module_project_ids=deleted_project_ids,
chat_task_ids=chat_task_ids,
shot_segment_ids=ids,
freed_size_bytes=int(project_freed_size or 0) + int(segment_freed_size or 0),
)
async def batch_delete_generation_history_items(
db: AsyncSession,
*,
current_user: User,
history_source: str,
ids: Sequence[str] | Iterable[str],
) -> GenerationAIHistoryBatchDeleteOut:
"""
按素材云 history_source 批量软删除历史记录。
事务由 get_db 统一提交/回滚;本服务只 flush,不主动 commit。
所有分支均为“先批量查询校验,再统一软删”,任何校验失败都会整体回滚。
"""
try:
source = normalize_generation_history_source(history_source)
except ValueError as exc:
raise HTTPException(status_code=400, detail="history_source 不支持") from exc
normalized_ids = _normalize_ids(ids)
deleted_at = datetime.now(timezone.utc)
if source == GenerationHistorySourceEnum.GENERATION_RECORD:
result = await _delete_generation_records(
db,
current_user=current_user,
source=source,
ids=normalized_ids,
deleted_at=deleted_at,
)
elif source == GenerationHistorySourceEnum.CHAT_TASK:
result = await _delete_chat_tasks(
db,
current_user=current_user,
source=source,
ids=normalized_ids,
deleted_at=deleted_at,
)
elif source == GenerationHistorySourceEnum.HOT_OPENING_REPLICATE:
result = await _delete_hot_opening_projects(
db,
current_user=current_user,
source=source,
ids=normalized_ids,
deleted_at=deleted_at,
)
elif source == GenerationHistorySourceEnum.SHOT_REPLICATE:
result = await _delete_shot_segments(
db,
current_user=current_user,
source=source,
ids=normalized_ids,
deleted_at=deleted_at,
)
else:
raise HTTPException(status_code=400, detail="history_source 不支持")
# await _log_history_batch_delete(db, current_user=current_user, result=result)
await db.flush()
return result
@@ -9,14 +9,17 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.common import ModuleEventTypeEnum, ModuleStepStatusEnum
from app.enums.generation_task import ChatGenerationPipelineStage, ChatGenerationTaskStatus
from app.models.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject
from app.models.module_generation_step import ModuleGenerationStep
from app.models.user import User
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.enums.module_generation_flow import ModuleGenerationFlowConfig
from app.services.module_generation_step_common_service import build_step_input, build_step_output, utc_now
from app.services.resource_accounting_service import soft_delete_chat_task_resources
from app.services.resource_accounting_service import (
SOURCE_MODEL_CHAT_TASK,
soft_delete_resources_by_source,
)
from app.utils.id_gen import generate_id
LogModuleEventCallable = Callable[..., Awaitable[None]]
@@ -224,6 +227,106 @@ def _clear_project_final_resources_by_deleted_steps(
return cleared_fields
ACTIVE_CHAT_TASK_BLOCK_STATUSES = {
ChatGenerationTaskStatus.PENDING.value,
ChatGenerationTaskStatus.GENERATING.value,
}
ACTIVE_CHAT_TASK_BLOCK_STAGES = {
ChatGenerationPipelineStage.QUEUED.value,
ChatGenerationPipelineStage.PREPARING.value,
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
ChatGenerationPipelineStage.WAITING_REMOTE.value,
ChatGenerationPipelineStage.POLLING.value,
ChatGenerationPipelineStage.RESULT_READY.value,
ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value,
ChatGenerationPipelineStage.DOWNLOADING.value,
ChatGenerationPipelineStage.RETRY_WAITING.value,
}
def is_active_chat_generation_task(task: ChatGenerationTask) -> bool:
"""判断 ChatGenerationTask 是否仍处于不可主动删除/废弃的处理中状态。"""
return bool(
task.status in ACTIVE_CHAT_TASK_BLOCK_STATUSES
or (task.pipeline_stage in ACTIVE_CHAT_TASK_BLOCK_STAGES)
)
def _unique_ids(values: list[str | None] | tuple[str | None, ...]) -> list[str]:
return list(dict.fromkeys(str(value) for value in values if value))
async def load_chat_tasks_for_steps(
db: AsyncSession,
steps: list[ModuleGenerationStep],
*,
for_update: bool = False,
) -> dict[str, ChatGenerationTask]:
ids = _unique_ids([step.chat_task_id for step in steps])
if not ids:
return {}
stmt = select(ChatGenerationTask).where(
ChatGenerationTask.id.in_(ids),
ChatGenerationTask.deleted_at.is_(None),
)
if for_update:
stmt = stmt.with_for_update()
result = await db.execute(stmt)
return {task.id: task for task in result.scalars().all()}
async def assert_no_active_chat_tasks_for_steps(
db: AsyncSession,
steps: list[ModuleGenerationStep],
*,
detail_message: str = "当前存在生成中任务,请等待生成完成或失败后再操作",
for_update: bool = True,
) -> dict[str, ChatGenerationTask]:
"""批量校验步骤关联任务是否有进行中任务。
用户主动修改上游步骤、删除项目、删除历史时,统一采用“生成中拦截、不退款”。
返回已批量加载的未删除 ChatGenerationTask,调用方可继续复用,避免重复查询。
"""
task_map = await load_chat_tasks_for_steps(db, steps, for_update=for_update)
active_task_ids = [task.id for task in task_map.values() if is_active_chat_generation_task(task)]
if active_task_ids:
raise HTTPException(
status_code=409,
detail={
"message": detail_message,
"active_count": len(active_task_ids),
},
)
return task_map
async def assert_project_has_no_active_chat_tasks(
db: AsyncSession,
*,
project: ModuleGenerationProject,
config: ModuleGenerationFlowConfig,
detail_message: str = "当前存在生成中任务,请等待生成完成或失败后再操作",
) -> dict[str, ChatGenerationTask]:
result = await db.execute(
select(ModuleGenerationStep)
.where(
ModuleGenerationStep.project_id == project.id,
ModuleGenerationStep.module == config.module,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.chat_task_id.is_not(None),
)
.with_for_update()
)
steps = list(result.scalars().all())
return await assert_no_active_chat_tasks_for_steps(
db,
steps,
detail_message=detail_message,
for_update=True,
)
async def soft_delete_steps_from_index(
db: AsyncSession,
*,
@@ -232,9 +335,17 @@ async def soft_delete_steps_from_index(
config: ModuleGenerationFlowConfig,
log_module_event: LogModuleEventCallable,
deleted_at: datetime | None = None,
refund_unfinished: bool = True,
refund_unfinished: bool = False,
block_active_tasks: bool = True,
release_stats: dict[str, int] | None = None,
) -> list[ModuleGenerationStep]:
"""软删指定步骤及其后续当前版本步骤。
说明:
- 兼容旧调用方保留 refund_unfinished 参数,但用户主动修改/删除链路不再退款。
- 存在 pending/generating/下载中/轮询中等任务时直接 409 拦截。
- 已完成任务只做软删任务与 generated_resources,释放容量统计;失败任务只软删任务。
"""
deleted_at = deleted_at or utc_now()
result = await db.execute(
select(ModuleGenerationStep)
@@ -248,6 +359,21 @@ async def soft_delete_steps_from_index(
.with_for_update()
)
steps = list(result.scalars().all())
if not steps:
return []
task_map = await load_chat_tasks_for_steps(db, steps, for_update=True)
if block_active_tasks:
active_task_ids = [task.id for task in task_map.values() if is_active_chat_generation_task(task)]
if active_task_ids:
raise HTTPException(
status_code=409,
detail={
"message": "当前存在生成中任务,请等待生成完成或失败后再操作",
"active_count": len(active_task_ids),
},
)
deleted_step_codes = {step.step_code for step in steps}
cleared_project_fields = _clear_project_final_resources_by_deleted_steps(
project,
@@ -255,30 +381,27 @@ async def soft_delete_steps_from_index(
config=config,
)
completed_task_ids = [
task.id
for task in task_map.values()
if task.status == ChatGenerationTaskStatus.COMPLETED.value
]
if completed_task_ids:
released_size = await soft_delete_resources_by_source(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=completed_task_ids,
deleted_at=deleted_at,
)
if release_stats is not None:
release_stats["released_size_bytes"] = int(release_stats.get("released_size_bytes", 0)) + int(released_size or 0)
for step in steps:
step.is_current = False
step.deleted_at = deleted_at
if step.chat_task_id:
chat_result = await db.execute(
select(ChatGenerationTask)
.where(ChatGenerationTask.id == step.chat_task_id, ChatGenerationTask.deleted_at.is_(None))
.with_for_update()
.limit(1)
)
chat_task = chat_result.scalar_one_or_none()
if chat_task:
if chat_task.status == "completed":
released_size = await soft_delete_chat_task_resources(db, chat_task.id, deleted_at=deleted_at)
if release_stats is not None:
release_stats["released_size_bytes"] = int(release_stats.get("released_size_bytes", 0)) + int(released_size or 0)
elif refund_unfinished and chat_task.status != "failed":
await mark_chat_generation_task_failed_and_refund_once(
db,
task=chat_task,
error_message=config.cancel_chat_task_error_message,
pipeline_stage="failed",
)
chat_task.deleted_at = deleted_at
if step.chat_task_id and step.chat_task_id in task_map:
task_map[step.chat_task_id].deleted_at = deleted_at
if steps:
await log_module_event(
db,
@@ -289,14 +412,12 @@ async def soft_delete_steps_from_index(
"step_ids": [step.id for step in steps],
"step_codes": [step.step_code for step in steps],
"cleared_project_fields": cleared_project_fields,
"block_active_tasks": block_active_tasks,
"refund_unfinished": False,
},
)
return steps
async def chat_tasks_by_id(db: AsyncSession, steps: list[ModuleGenerationStep]) -> dict[str, ChatGenerationTask]:
ids = [step.chat_task_id for step in steps if step.chat_task_id]
if not ids:
return {}
result = await db.execute(select(ChatGenerationTask).where(ChatGenerationTask.id.in_(ids)))
return {task.id: task for task in result.scalars().all()}
return await load_chat_tasks_for_steps(db, steps, for_update=False)
@@ -12,7 +12,6 @@ from sqlalchemy.orm.attributes import flag_modified
from app.config import settings
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
from app.enums.generation_task import ChatGenerationPipelineStage, ChatGenerationTaskStatus
from app.enums.shot_replicate import ShotReplicateGenerationModeEnum, ShotReplicateStepCodeEnum, ModuleCodeEnum
from app.models.chat_generation_task import ChatGenerationTask
from app.models.module_generation_project import ModuleGenerationProject
@@ -55,6 +54,7 @@ from app.services.module_generation_log_service import log_module_error, log_mod
from app.services.llm import optimize_prompt
from app.services.resource_accounting_service import soft_delete_chat_task_resources
from app.services.module_generation_flow_base_service import (
assert_project_has_no_active_chat_tasks as _base_assert_project_has_no_active_chat_tasks,
chat_tasks_by_id as _base_chat_tasks_by_id,
create_module_step as _base_create_step,
get_current_step_by_code as _base_get_current_step_by_code,
@@ -336,7 +336,7 @@ async def _soft_delete_steps_from_index(
project: ModuleGenerationProject,
start_index: int,
deleted_at: datetime | None = None,
refund_unfinished: bool = True,
refund_unfinished: bool = False,
release_stats: dict[str, int] | None = None,
) -> None:
await _base_soft_delete_steps_from_index(
@@ -1412,57 +1412,18 @@ async def handle_chat_generation_task_failed(db: AsyncSession, task: ChatGenerat
_ACTIVE_DELETE_BLOCK_STATUSES = {
ChatGenerationTaskStatus.PENDING.value,
ChatGenerationTaskStatus.GENERATING.value,
}
_ACTIVE_DELETE_BLOCK_STAGES = {
ChatGenerationPipelineStage.QUEUED.value,
ChatGenerationPipelineStage.PREPARING.value,
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
ChatGenerationPipelineStage.WAITING_REMOTE.value,
ChatGenerationPipelineStage.POLLING.value,
ChatGenerationPipelineStage.RESULT_READY.value,
ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value,
ChatGenerationPipelineStage.DOWNLOADING.value,
ChatGenerationPipelineStage.RETRY_WAITING.value,
}
async def _assert_project_has_no_active_chat_tasks_for_delete(
db: AsyncSession,
*,
project: ModuleGenerationProject,
) -> None:
"""用户主动删除项目/切片时不退款;如仍有异步生成任务进行中,直接拦截。"""
step_result = await db.execute(
select(ModuleGenerationStep.chat_task_id)
.where(
ModuleGenerationStep.project_id == project.id,
ModuleGenerationStep.module == MODULE,
ModuleGenerationStep.deleted_at.is_(None),
ModuleGenerationStep.chat_task_id.is_not(None),
)
await _base_assert_project_has_no_active_chat_tasks(
db,
project=project,
config=FLOW_CONFIG,
detail_message="当前拆镜复刻项目仍有生成中任务,暂不能删除",
)
chat_task_ids = [task_id for task_id in step_result.scalars().all() if task_id]
if not chat_task_ids:
return
task_result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id.in_(chat_task_ids),
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
)
active_tasks = []
for task in task_result.scalars().all():
if task.status in _ACTIVE_DELETE_BLOCK_STATUSES or (task.pipeline_stage in _ACTIVE_DELETE_BLOCK_STAGES):
active_tasks.append(task.id)
if active_tasks:
raise HTTPException(status_code=400, detail="当前拆镜复刻项目仍有生成中任务,暂不能删除")
async def mark_shot_replicate_step_dispatch_failed(
@@ -9,6 +9,7 @@ from app.config import settings
from app.models.user_oauth import UserOAuth
from app.models.user_oauth_app import UserOAuthApp
from app.utils.id_gen import generate_id
from app.tasks.token_refresh_task import _update_redis_token
#随机获取一个可用的应用配置
async def get_available_app(open_type: int, db: AsyncSession) -> dict:
@@ -172,10 +173,13 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
)
oauth = oauth.scalar_one_or_none()
if not oauth:
new_oauth_ids = []
for account in account_list:
oauth_id = generate_id()
new_oauth_ids.append(oauth_id)
#新增授权记录
db.add(UserOAuth(
id=generate_id(),
id=oauth_id,
account_id = str(account.get("account_id", "")),
account_name = account.get("account_name", ""),
account_role = account.get("account_role", ""),
@@ -191,7 +195,11 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
refresh_token_expired = refresh_token_expires_in,
material_auth_status = material_auth_status,
))
await db.commit()
await db.commit()
for oauth_id in new_oauth_ids:
await _update_redis_token(oauth_id, access_token, expires_in)
else:
# 查询现有授权记录(未删除的)
existing_accounts = await db.execute(
@@ -209,6 +217,9 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
new_account_ids = {str(account.get("account_id")) for account in account_list}
old_account_ids = set(existing_accounts.keys())
# 需要更新Redis的oauth_id列表
update_redis_ids = []
# 1. 软删除已消失的账户
for account_id in old_account_ids - new_account_ids:
existing_accounts[account_id].deleted_at = datetime.now()
@@ -226,10 +237,13 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
existing_oauth.refresh_token = refresh_token
existing_oauth.refresh_token_expired = refresh_token_expires_in
existing_oauth.material_auth_status = material_auth_status
update_redis_ids.append(existing_oauth.id)
else:
# 新增记录
oauth_id = generate_id()
update_redis_ids.append(oauth_id)
db.add(UserOAuth(
id=generate_id(),
id=oauth_id,
account_id=str(account_id),
account_name=account.get("account_name", ""),
account_role=account.get("account_role", ""),
@@ -246,8 +260,45 @@ async def get_juliang_token(app_id: str, secret: str, code: str, open_type: int,
material_auth_status=material_auth_status,
))
await db.commit()
for oauth_id in update_redis_ids:
await _update_redis_token(oauth_id, access_token, expires_in)
#5.本次更新成功以后,判断是否有其他同一个appid,同一个授权登录账号的授权记录,如果有,则更新token信息
from sqlalchemy import update
related_oauths = await db.execute(
select(UserOAuth).where(
UserOAuth.account_username == account_username,
UserOAuth.account_userid == account_userid,
UserOAuth.appid == app_id,
UserOAuth.user_id != user_id,
UserOAuth.deleted_at.is_(None),
)
)
related_oauths = related_oauths.scalars().all()
if related_oauths:
await db.execute(
update(UserOAuth).where(
UserOAuth.account_username == account_username,
UserOAuth.account_userid == account_userid,
UserOAuth.appid == app_id,
UserOAuth.user_id != user_id,
UserOAuth.deleted_at.is_(None),
).values(
access_token=access_token,
access_token_expired=expires_in,
refresh_token=refresh_token,
refresh_token_expired=refresh_token_expires_in,
material_auth_status=material_auth_status,
)
)
await db.commit()
for related_oauth in related_oauths:
await _update_redis_token(related_oauth.id, access_token, expires_in)
#5.返回成功
return {"message": "授权成功"}
async def get_kuaishou_token(app_id: str, secret: str, code: str, oauth_type: int, user_id: str, db: AsyncSession) -> dict:
+5 -2
View File
@@ -126,12 +126,15 @@ async def submit_video_task(
for ref in refs:
ref_type = ref.get("type")
ref_url = ref.get("url", "")
ref_role = ref.get("role")
if ref_type == "image" and ref_url:
resolved = _resolve_url(ref_url)
content.append({"type": "image_url", "image_url": {"url": resolved},"role":"reference_image"})
role = ref_role if ref_role in ("first_frame", "last_frame") else "reference_image"
content.append({"type": "image_url", "image_url": {"url": resolved}, "role": role})
elif ref_type == "video" and ref_url:
resolved = _resolve_url(ref_url)
content.append({"type": "video_url", "video_url": {"url": resolved},"role":"reference_video"})
role = ref_role if ref_role else "reference_video"
content.append({"type": "video_url", "video_url": {"url": resolved}, "role": role})
except (json.JSONDecodeError, TypeError):
pass
+95 -34
View File
@@ -60,12 +60,33 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
#如果code=40103或者40107,传入refresh_token已失效,失效原因一般是由于refresh_token已被使用,或授权账号重新授权并生成了新的Token
if data.get("code") in [40103, 40107]:
#清空数据库中的token信息,和Redis缓存中的token
oauth.access_token = None
oauth.access_token_expired = None
oauth.refresh_token = None
oauth.refresh_token_expired = None
from sqlalchemy import update
where_cond = UserOAuth.deleted_at.is_(None)
if oauth.appid:
where_cond = where_cond & (UserOAuth.appid == oauth.appid)
if oauth.account_username:
where_cond = where_cond & (UserOAuth.account_username == oauth.account_username)
if oauth.account_userid:
where_cond = where_cond & (UserOAuth.account_userid == oauth.account_userid)
await db.execute(
update(UserOAuth).where(where_cond).values(
access_token=None,
access_token_expired=None,
refresh_token=None,
refresh_token_expired=None,
)
)
await db.commit()
await _update_redis_token(oauth.id, "", None)
related_oauth_ids = await db.execute(
select(UserOAuth.id).where(where_cond)
)
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
for related_id in related_oauth_ids:
await _update_redis_token(related_id, "", None)
return
@@ -75,15 +96,34 @@ async def refresh_juliang_token(oauth: UserOAuth, app: UserOAuthApp, db: AsyncSe
expires_in = datetime.now(tz=oauth.access_token_expired.tzinfo) + timedelta(seconds=data.get("expires_in", 0))
refresh_token_expires_in = datetime.now(tz=oauth.refresh_token_expired.tzinfo) + timedelta(seconds=data.get("refresh_token_expires_in", 0))
oauth.access_token = new_access_token
oauth.access_token_expired = expires_in
oauth.refresh_token = new_refresh_token
oauth.refresh_token_expires_in = refresh_token_expires_in
from sqlalchemy import update
where_cond = UserOAuth.deleted_at.is_(None)
if oauth.appid:
where_cond = where_cond & (UserOAuth.appid == oauth.appid)
if oauth.account_username:
where_cond = where_cond & (UserOAuth.account_username == oauth.account_username)
if oauth.account_userid:
where_cond = where_cond & (UserOAuth.account_userid == oauth.account_userid)
await db.execute(
update(UserOAuth).where(where_cond).values(
access_token=new_access_token,
access_token_expired=expires_in,
refresh_token=new_refresh_token,
refresh_token_expired=refresh_token_expires_in,
)
)
await db.commit()
await _update_redis_token(oauth.id, new_access_token, expires_in)
related_oauth_ids = await db.execute(
select(UserOAuth.id).where(where_cond)
)
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
for related_id in related_oauth_ids:
await _update_redis_token(related_id, new_access_token, expires_in)
logger.info(f"成功刷新巨量引擎token: oauth_id={oauth.id}, account_id={oauth.account_id}")
logger.info(f"成功刷新巨量引擎token: oauth_id={oauth.id}, account_id={oauth.account_id}, 关联账户数={len(related_oauth_ids)}")
except httpx.HTTPError as e:
logger.error(f"HTTP请求失败: oauth_id={oauth.id}, 错误: {str(e)}")
except Exception as e:
@@ -97,37 +137,39 @@ async def check_and_refresh_tokens():
query = select(UserOAuth).where(
UserOAuth.deleted_at.is_(None),
UserOAuth.access_token.is_not(None),
UserOAuth.access_token_expired.is_not(None),
UserOAuth.refresh_token.is_not(None),
UserOAuth.refresh_token_expired.is_not(None),
UserOAuth.refresh_token_expired > now,
)
result = await db.execute(query)
oauth_list = result.scalars().all()
refreshed_keys = set()
for oauth in oauth_list:
try:
#1.检查access_token是否过期,如果未过期,并且大于800秒,直接跳过不处理
if not oauth.access_token_expired:
#检查是否为支持的平台(巨量引擎)
# port_type: 平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)
if oauth.port_type not in [1]:
continue
remaining_seconds = (oauth.access_token_expired - now).total_seconds()
#构建登录账号唯一标识,同一登录账号共享token
key_parts = []
if oauth.appid:
key_parts.append(oauth.appid)
if oauth.account_username:
key_parts.append(oauth.account_username)
if oauth.account_userid:
key_parts.append(oauth.account_userid)
login_key = "|".join(key_parts)
# access_token剩余时间大于等于800秒,不需要刷新
if remaining_seconds >= REFRESH_THRESHOLD_SECONDS:
#同一登录账号已刷新过,直接跳过(避免使用旧数据判断)
if login_key in refreshed_keys:
logger.debug(f"跳过重复刷新: oauth_id={oauth.id}, 同一登录账号已刷新")
continue
#2.如果access_token过期,或者剩余时间小于800秒,需要刷新token
#3.如果需要刷新token,检查refresh_token是否过期,如果refresh_token过期,说明不可刷新,需要直接重新授权,直接跳过不处理
if not oauth.refresh_token_expired:
continue
refresh_remaining_seconds = (oauth.refresh_token_expired - now).total_seconds()
if refresh_remaining_seconds <= 0:
continue
#5.获取应用配置
#获取应用配置
app_result = await db.execute(
select(UserOAuthApp).where(UserOAuthApp.app_id == oauth.appid)
)
@@ -135,12 +177,31 @@ async def check_and_refresh_tokens():
if not app:
continue
#检查是否为支持的平台(巨量引擎)
# port_type: 平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)
if oauth.port_type in [1]:
#刷新token
await refresh_juliang_token(oauth, app, db)
#检查access_token是否需要刷新
need_refresh = False
# access_token为空,需要刷新
if not oauth.access_token:
need_refresh = True
# access_token_expired为空,需要刷新
elif not oauth.access_token_expired:
need_refresh = True
# access_token即将过期(剩余时间小于800秒),需要刷新
else:
remaining_seconds = (oauth.access_token_expired - now).total_seconds()
if remaining_seconds < REFRESH_THRESHOLD_SECONDS:
need_refresh = True
if not need_refresh:
continue
#refresh_token已在查询条件中过滤,确保有效才能刷新
#刷新token
await refresh_juliang_token(oauth, app, db)
refreshed_keys.add(login_key)
except Exception as e:
#7.增加错误日志
+66 -19
View File
@@ -89,7 +89,6 @@ class DouyinRequest:
if token:
return token
async with async_session() as db:
oauth_data = await db.execute(
select(UserOAuth).where(
@@ -103,17 +102,30 @@ class DouyinRequest:
raise ValueError("无效的oauth_id")
if force_refresh:
where_cond = UserOAuth.deleted_at.is_(None)
if oauth_data.appid:
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
if oauth_data.account_username:
where_cond = where_cond & (UserOAuth.account_username == oauth_data.account_username)
if oauth_data.account_userid:
where_cond = where_cond & (UserOAuth.account_userid == oauth_data.account_userid)
await db.execute(
update(UserOAuth).where(UserOAuth.id == oauth_id).values(
update(UserOAuth).where(where_cond).values(
access_token=None,
access_token_expired=None,
)
)
await db.commit()
await self._delete_redis_token(oauth_id)
related_oauth_ids = await db.execute(
select(UserOAuth.id).where(where_cond)
)
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
for related_id in related_oauth_ids:
await self._delete_redis_token(related_id)
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
await self._set_redis_token(oauth_id, new_token, new_expired_at)
return new_token
if oauth_data.access_token_expired and oauth_data.access_token_expired > datetime.now(timezone.utc):
@@ -122,14 +134,46 @@ class DouyinRequest:
await self._set_redis_token(oauth_id, token, expired_at)
return token
where_cond = UserOAuth.deleted_at.is_(None)
if oauth_data.appid:
where_cond = where_cond & (UserOAuth.appid == oauth_data.appid)
if oauth_data.account_username:
where_cond = where_cond & (UserOAuth.account_username == oauth_data.account_username)
if oauth_data.account_userid:
where_cond = where_cond & (UserOAuth.account_userid == oauth_data.account_userid)
related_oauths = await db.execute(
select(UserOAuth.access_token, UserOAuth.access_token_expired).where(
where_cond,
UserOAuth.access_token_expired.is_not(None),
UserOAuth.access_token_expired > datetime.now(timezone.utc),
).limit(1)
)
related_oauth = related_oauths.first()
if related_oauth:
token, expired_at = related_oauth
await self._set_redis_token(oauth_id, token, expired_at)
return token
if oauth_data.refresh_token_expired and oauth_data.refresh_token_expired < datetime.now(timezone.utc):
raise ValueError("授权已过期,请重新授权")
new_token, new_expired_at = await self.refresh_access_token(db, oauth_id, oauth_data.appid, oauth_data.refresh_token)
await self._set_redis_token(oauth_id, new_token, new_expired_at)
return new_token
async def refresh_access_token(self, db: AsyncSession, oauth_id: str, appid: str, refresh_token: str) -> Tuple[str, datetime]:
oauth_info = await db.execute(
select(UserOAuth.account_username, UserOAuth.account_userid).where(
UserOAuth.id == oauth_id,
UserOAuth.deleted_at.is_(None),
).limit(1)
)
oauth_info = oauth_info.first()
if not oauth_info:
raise ValueError("无效的oauth_id")
account_username, account_userid = oauth_info
result = await db.execute(
select(UserOAuthApp.secret).where(
UserOAuthApp.app_id == appid,
@@ -169,9 +213,17 @@ class DouyinRequest:
new_expired_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
where_cond = UserOAuth.deleted_at.is_(None)
if appid:
where_cond = where_cond & (UserOAuth.appid == appid)
if account_username:
where_cond = where_cond & (UserOAuth.account_username == account_username)
if account_userid:
where_cond = where_cond & (UserOAuth.account_userid == account_userid)
await db.execute(
update(UserOAuth).where(
UserOAuth.id == oauth_id,
where_cond,
).values(
access_token=new_access_token,
refresh_token=new_refresh_token,
@@ -181,6 +233,14 @@ class DouyinRequest:
)
await db.commit()
related_oauth_ids = await db.execute(
select(UserOAuth.id).where(where_cond)
)
related_oauth_ids = [row[0] for row in related_oauth_ids.all()]
for related_id in related_oauth_ids:
await self._set_redis_token(related_id, new_access_token, new_expired_at)
return new_access_token, new_expired_at
# 有token请求
@@ -267,19 +327,6 @@ class DouyinRequest:
else:
raise ValueError('网络错误,稍后重试。')
# options_log = {}
# if options:
# for key, value in options.items():
# if key == 'files':
# options_log[key] = {k: (v[0], 'bytes_content', v[2]) for k, v in value.items()}
# else:
# options_log[key] = value
# raise RuntimeError(
# f'DouYin API request failed after 5 retries. '
# f'url:{url};oauthId:{oauth_id};options:{json.dumps(options_log)};response:{res}'
# )
# if code != 0:
# raise ValueError(f'response:{res}')
# 无token请求
async def request_with_context(
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -28,8 +28,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-B5pqnZWD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BhPFzWLH.css">
<script type="module" crossorigin src="/assets/index-dEsTkAeV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bi8mcSs8.css">
</head>
<body>
<div id="root"></div>
+649 -129
View File
@@ -65,6 +65,8 @@ interface MediaReference {
type: 'image' | 'video';
url: string;
duration?: number;
role?: string;
label?: string;
}
interface Message {
@@ -156,6 +158,13 @@ const AIChatPage: React.FC = () => {
const [loading, setLoading] = useState<boolean>(false);
const [referenceMode, setReferenceMode] = useState<'universal' | 'first_last_frame'>('universal');
const [firstFrame, setFirstFrame] = useState<MediaReference | null>(null);
const [lastFrame, setLastFrame] = useState<MediaReference | null>(null);
const [uploadTarget, setUploadTarget] = useState<'first' | 'last' | null>(null);
const [referenceModeDropdownVisible, setReferenceModeDropdownVisible] = useState(false);
const [mediaStackHovered, setMediaStackHovered] = useState(false);
// @ 提及相关状态
const [mentionVisible, setMentionVisible] = useState(false);
const mentionInputRef = useRef<any>(null);
@@ -408,6 +417,24 @@ const AIChatPage: React.FC = () => {
};
}, [showEngineModal, showImageSettingsModal, showVideoSettingsModal]);
useEffect(() => {
if (mediaType !== 'video') return;
const engine = enginesele?.video?.find((e: any) => e.id === countType);
if (!engine) return;
const supportsFLF = engine.supportsFirstLastFrame ?? false;
const supportsUR = engine.supportsUniversalReference ?? true;
setReferenceMode((prevMode) => {
if (prevMode === 'first_last_frame' && !supportsFLF) {
if (supportsUR) return 'universal';
} else if (prevMode === 'universal' && !supportsUR) {
if (supportsFLF) return 'first_last_frame';
}
return prevMode;
});
}, [countType, mediaType, enginesele]);
// 初始化获取参数 - 只在组件挂载时执行一次
useEffect(() => {
getEngine()
@@ -648,6 +675,31 @@ const AIChatPage: React.FC = () => {
setHeight(width);
};
const currentVideoEngine = enginesele?.video?.find((e: any) => e.id === countType);
const supportsFirstLastFrame = currentVideoEngine?.supportsFirstLastFrame ?? false;
const supportsUniversalReference = currentVideoEngine?.supportsUniversalReference ?? true;
const handleReferenceModeChange = (mode: 'universal' | 'first_last_frame') => {
if (mode === 'first_last_frame' && !supportsFirstLastFrame) return;
if (mode === 'universal' && !supportsUniversalReference) return;
setReferenceMode(mode);
setReferenceModeDropdownVisible(false);
};
const handleSwapFrames = () => {
const temp = firstFrame;
setFirstFrame(lastFrame);
setLastFrame(temp);
};
const handleRemoveFirstFrame = () => {
setFirstFrame(null);
};
const handleRemoveLastFrame = () => {
setLastFrame(null);
};
// ==================== 事处理函数 ====================
const handleNewChat = () => {
@@ -668,6 +720,8 @@ const AIChatPage: React.FC = () => {
// 清空输入框和已上传媒体
setInputValue('');
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
// 显示提示消息
message.info('已开启新对话');
@@ -693,14 +747,44 @@ const AIChatPage: React.FC = () => {
const handleSelectChat = (conversationId: string) => {
setCurrentConversationId(conversationId);
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
};
const handleSend = async () => {
// 验证:必须有内容或图片或视频
if (!inputValue.trim() && currentMedia.length === 0) {
message.warning('请输入内容或上传图片/视频');
return;
const isFirstLastFrameMode = mediaType === 'video' && referenceMode === 'first_last_frame';
if (isFirstLastFrameMode) {
if (!inputValue.trim() && !firstFrame) {
message.warning('请输入内容或上传首帧图片');
return;
}
if (!firstFrame) {
message.warning('请上传首帧图片');
return;
}
} else {
if (!inputValue.trim() && currentMedia.length === 0) {
message.warning('请输入内容或上传图片/视频');
return;
}
}
let mediaReferences: MediaReference[] | undefined;
if (isFirstLastFrameMode) {
mediaReferences = [];
if (firstFrame) {
mediaReferences.push({ ...firstFrame, role: 'first_frame' });
}
if (lastFrame) {
mediaReferences.push({ ...lastFrame, role: 'last_frame' });
}
if (mediaReferences.length === 0) {
mediaReferences = undefined;
}
} else {
mediaReferences = currentMedia.length > 0 ? [...currentMedia] : undefined;
}
// 创建用户消息对象
@@ -711,8 +795,7 @@ const AIChatPage: React.FC = () => {
engine_id: countType,
idempotency_key: new Date().toLocaleString('zh-CN'),
// 统一的媒体数组,包含 name、type、url
media_references: currentMedia.length > 0 ? [...currentMedia] : undefined,
media_references: mediaReferences,
// 图片参数(仅图片模式时添加)
...(mediaType === 'image' && {
image_size: selectedResolution,
@@ -739,6 +822,8 @@ const AIChatPage: React.FC = () => {
// 创建任务成功后,清空输入框和已上传媒体
setInputValue('');
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
// 创建任务成功后,重置页数为1,获取最新列表
const newPagebreak = { ...Pagebreak, page: 1 };
@@ -866,49 +951,79 @@ const AIChatPage: React.FC = () => {
};
const handleUpload = async (file: File) => {
// 验证文件类型
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
if (mediaType === 'video' && referenceMode === 'first_last_frame') {
if (!isImage) {
message.error('首尾帧模式仅支持上传图片');
return false;
}
if (file.size / 1024 / 1024 > 10) {
message.error('图片大小不能超过10MB');
return false;
}
if (!uploadTarget) {
return false;
}
setUploading(true);
try {
const res = await uploadImage(file);
const mediaRef: MediaReference = {
name: file.name,
type: 'image',
url: res.url,
role: uploadTarget === 'first' ? 'first_frame' : 'last_frame',
};
if (uploadTarget === 'first') {
setFirstFrame(mediaRef);
} else {
setLastFrame(mediaRef);
}
message.success('图片上传成功');
} catch (error) {
message.error('上传失败');
} finally {
setUploading(false);
setUploadTarget(null);
}
return false;
}
if (!isImage && !isVideo) {
message.error('仅支持图片或视频文件');
return false;
}
// 获取当前选中引擎的限制
const currentEngineList = mediaType === 'image' ? enginesele.image : enginesele.video;
const currentEngine = currentEngineList?.find((e: any) => e.id === countType);
const maxImage = currentEngine?.maxImageCount ?? 4;
const maxVideo = currentEngine?.maxVideoCount ?? 1;
// 根据 mediaType 判断是否允许该文件类型
if (mediaType === 'image' && isVideo) {
message.error('图片模式仅支持上传图片');
return false;
}
// 验证文件大小
const maxMB = isVideo ? 100 : 10;
if (file.size / 1024 / 1024 > maxMB) {
message.error(`${isVideo ? '视频' : '图片'}大小不能超过${maxMB}MB`);
return false;
}
// 验证图片数量
const imageCount = currentMedia.filter((m) => m.type === 'image').length;
if (isImage && imageCount >= maxImage) {
message.error(`该引擎最多上传${maxImage}张图片`);
return false;
}
// 验证视频数量
const videoCount = currentMedia.filter((m) => m.type === 'video').length;
if (isVideo && videoCount >= maxVideo) {
message.error(`该引擎最多上传${maxVideo}个视频`);
return false;
}
// 获取视频时长并验证
let videoDuration = 0;
if (isVideo) {
try {
@@ -930,14 +1045,11 @@ const AIChatPage: React.FC = () => {
}
}
// 设置上传状态
setUploading(true);
try {
// 根据文件类型调用相应的上传函数
const uploadFn = isImage ? uploadImage : uploadVideo;
const res = await uploadFn(file);
// 添加到统一的媒体列表
const mediaType: 'image' | 'video' = isImage ? 'image' : 'video';
const newList = [...currentMedia, {
name: file.name,
@@ -955,7 +1067,6 @@ const AIChatPage: React.FC = () => {
setUploading(false);
}
// 返回false阻止Ant Design的自动上传行为
return false;
};
@@ -1341,14 +1452,30 @@ const AIChatPage: React.FC = () => {
e.stopPropagation();
setInputValue(msg.originalPrompt || '');
if (msg.mediaReferences && msg.mediaReferences.length > 0) {
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
name: ref.name,
type: ref.type,
url: ref.url,
label: ref.label || '',
})));
const hasFirstLastFrame = msg.mediaReferences.some((ref: any) => ref.role === 'first_frame' || ref.role === 'last_frame');
if (hasFirstLastFrame && msg.genType === 'video') {
const first = msg.mediaReferences.find((ref: any) => ref.role === 'first_frame');
const last = msg.mediaReferences.find((ref: any) => ref.role === 'last_frame');
setFirstFrame(first ? { ...first, label: first.label || '' } : null);
setLastFrame(last ? { ...last, label: last.label || '' } : null);
setCurrentMedia([]);
setReferenceMode('first_last_frame');
} else {
setCurrentMedia(msg.mediaReferences.map((ref: any) => ({
name: ref.name,
type: ref.type,
url: ref.url,
label: ref.label || '',
role: ref.role,
})));
setFirstFrame(null);
setLastFrame(null);
setReferenceMode('universal');
}
} else {
setCurrentMedia([]);
setFirstFrame(null);
setLastFrame(null);
}
msgApi.success('已加载到编辑区');
}}
@@ -1680,8 +1807,8 @@ const AIChatPage: React.FC = () => {
style={{
background: 'rgba(255,255,255,0.1)',
backdropFilter: 'blur(20px)',
borderRadius: 24,
padding: 16,
borderRadius: 28,
padding: 20,
boxShadow: '0 8px 32px rgba(99, 102, 241, 0.1), 0 2px 8px rgba(0,0,0,0.04)',
transition: 'all 0.3s ease',
border: '1px solid rgba(99, 102, 241, 0.08)',
@@ -1689,63 +1816,275 @@ const AIChatPage: React.FC = () => {
}}
>
{/* 已上传媒体预览 */}
{currentMedia.length > 0 && (
<div style={{ display: 'flex', gap: 8, marginBottom: 12, overflowX: 'auto' }}>
{currentMedia.map((media, idx) => (
<div key={`media-${idx}`} style={{ position: 'relative', width: media.type === 'video' ? 120 : 80, flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
{media.type === 'image' ? (
{mediaType === 'video' && referenceMode === 'first_last_frame' ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 16, marginBottom: 16, padding: '8px 0' }}>
{/* 首帧 */}
<div style={{ position: 'relative', width: 100, height: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<span style={{ position: 'absolute', top: -20, left: 0, fontSize: 11, fontWeight: 600, color: '#6366f1', zIndex: 10 }}></span>
{firstFrame ? (
<div style={{ position: 'relative', width: 100, height: 100 }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
alt={media.name}
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${firstFrame.url}`}
alt={firstFrame.name}
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewUrl(firstFrame.url);
setAttachmentPreviewType('image');
setAttachmentPreviewName(media.name);
setAttachmentPreviewName(firstFrame.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8, cursor: 'pointer' }}
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 12, cursor: 'pointer', border: '2px solid rgba(99, 102, 241, 0.3)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }}
/>
) : (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
muted
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('video');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
<button
onClick={handleRemoveFirstFrame}
style={{
position: 'absolute',
top: -6,
right: -6,
width: 22,
height: 22,
border: 'none',
background: '#ef4444',
borderRadius: 50,
cursor: 'pointer',
color: '#fff',
fontSize: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.4)',
zIndex: 10,
}}
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 8, cursor: 'pointer' }}
/>
)}
<span style={{ fontSize: 11, color: '#64748b', fontWeight: 500 }}>
{media.label}
</span>
{/* 删除已上传媒体按钮 */}
<button
onClick={() => handleRemoveMedia(idx)}
style={{
position: 'absolute',
top: 0,
right: 0,
width: 20,
height: 20,
border: 'none',
background: '#ff4d4f',
borderRadius: 50,
cursor: 'pointer',
color: '#fff',
fontSize: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<DeleteOutlined style={{ fontSize: 11 }} />
</button>
</div>
) : (
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => { setUploadTarget('first'); return handleUpload(file); }}
>
<DeleteOutlined style={{ fontSize: 12 }} />
</button>
</div>
))}
<div
style={{
width: 100,
height: 100,
borderRadius: 12,
border: '2px dashed rgba(99, 102, 241, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.25s ease',
backgroundColor: 'rgba(99, 102, 241, 0.04)',
flexDirection: 'column',
gap: 4,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.3)';
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.04)';
}}
>
<PlusOutlined style={{ fontSize: 20, color: '#6366f1' }} />
<span style={{ fontSize: 10, color: '#6366f1', fontWeight: 500 }}></span>
</div>
</Upload>
)}
</div>
{/* 调换按钮 */}
<button
onClick={handleSwapFrames}
disabled={!firstFrame || !lastFrame}
style={{
width: 36,
height: 36,
borderRadius: 50,
border: 'none',
background: firstFrame && lastFrame ? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)' : '#e2e8f0',
cursor: firstFrame && lastFrame ? 'pointer' : 'not-allowed',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: firstFrame && lastFrame ? '0 4px 12px rgba(99, 102, 241, 0.4)' : 'none',
transition: 'all 0.2s ease',
flexShrink: 0,
}}
onMouseEnter={(e) => {
if (firstFrame && lastFrame) {
e.currentTarget.style.transform = 'scale(1.1)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<SwapOutlined style={{ fontSize: 16 }} />
</button>
{/* 尾帧 */}
<div style={{ position: 'relative', width: 100, height: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<span style={{ position: 'absolute', top: -20, left: 0, fontSize: 11, fontWeight: 600, color: '#8b5cf6', zIndex: 10 }}></span>
{lastFrame ? (
<div style={{ position: 'relative', width: 100, height: 100 }}>
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${lastFrame.url}`}
alt={lastFrame.name}
onClick={() => {
setAttachmentPreviewUrl(lastFrame.url);
setAttachmentPreviewType('image');
setAttachmentPreviewName(lastFrame.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: 100, height: 100, objectFit: 'cover', borderRadius: 12, cursor: 'pointer', border: '2px solid rgba(139, 92, 246, 0.3)', boxShadow: '0 4px 12px rgba(139, 92, 246, 0.15)' }}
/>
<button
onClick={handleRemoveLastFrame}
style={{
position: 'absolute',
top: -6,
right: -6,
width: 22,
height: 22,
border: 'none',
background: '#ef4444',
borderRadius: 50,
cursor: 'pointer',
color: '#fff',
fontSize: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(239, 68, 68, 0.4)',
zIndex: 10,
}}
>
<DeleteOutlined style={{ fontSize: 11 }} />
</button>
</div>
) : (
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => { setUploadTarget('last'); return handleUpload(file); }}
>
<div
style={{
width: 100,
height: 100,
borderRadius: 12,
border: '2px dashed rgba(139, 92, 246, 0.3)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.25s ease',
backgroundColor: 'rgba(139, 92, 246, 0.04)',
flexDirection: 'column',
gap: 4,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#8b5cf6';
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.3)';
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)';
}}
>
<PlusOutlined style={{ fontSize: 20, color: '#8b5cf6' }} />
<span style={{ fontSize: 10, color: '#8b5cf6', fontWeight: 500 }}></span>
</div>
</Upload>
)}
</div>
</div>
) : (
currentMedia.length > 0 && (
<div
style={{ position: 'relative', marginBottom: 12, minHeight: 90, paddingLeft: 8 }}
onMouseEnter={() => setMediaStackHovered(true)}
onMouseLeave={() => setMediaStackHovered(false)}
>
<div style={{ display: 'flex', position: 'relative' }}>
{currentMedia.map((media, idx) => {
const reversedIdx = currentMedia.length - 1 - idx;
const offset = mediaStackHovered ? idx * 88 : idx * 4;
return (
<div
key={`media-${idx}`}
style={{
position: 'absolute',
left: offset,
top: 0,
zIndex: idx + 1,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: `scale(${1 - reversedIdx * 0.03})`,
opacity: mediaStackHovered ? 1 : (1 - reversedIdx * 0.15),
}}
>
<div style={{ position: 'relative', width: media.type === 'video' ? 100 : 80, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
{media.type === 'image' ? (
<img
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
alt={media.name}
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('image');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '2px solid rgba(255,255,255,0.8)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }}
/>
) : (
<video
src={`${import.meta.env.VITE_API_BASE || "http://localhost:8000"}${media.url}`}
muted
onClick={() => {
setAttachmentPreviewUrl(media.url);
setAttachmentPreviewType('video');
setAttachmentPreviewName(media.name);
setAttachmentPreviewVisible(true);
}}
style={{ width: '100%', height: 80, objectFit: 'cover', borderRadius: 10, cursor: 'pointer', border: '2px solid rgba(255,255,255,0.8)', boxShadow: '0 4px 12px rgba(99, 102, 241, 0.15)' }}
/>
)}
<span style={{ fontSize: 10, color: '#64748b', fontWeight: 500 }}>
{media.label}
</span>
<button
onClick={() => handleRemoveMedia(idx)}
style={{
position: 'absolute',
top: -4,
right: -4,
width: 20,
height: 20,
border: 'none',
background: '#ef4444',
borderRadius: 50,
cursor: 'pointer',
color: '#fff',
fontSize: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 6px rgba(239, 68, 68, 0.4)',
}}
>
<DeleteOutlined style={{ fontSize: 10 }} />
</button>
</div>
</div>
);
})}
</div>
</div>
)
)}
{/* 输入框区域 */}
@@ -1753,57 +2092,59 @@ const AIChatPage: React.FC = () => {
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '12px 16px',
padding: '14px 18px',
backgroundColor: 'rgba(248,250,252,0.6)',
borderRadius: 16,
borderRadius: 18,
border: '1px solid rgba(99, 102, 241, 0.08)',
boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)',
transition: 'all 0.2s ease',
position: 'relative',
}}>
{/* 上传按钮 */}
<Upload
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
showUploadList={false}
beforeUpload={handleUpload}
>
<Tooltip title={mediaType === 'image'
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
}>
<div
style={{
width: 48,
height: 48,
borderRadius: 14,
border: '2px dashed rgba(99, 102, 241, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.25s ease',
flexShrink: 0,
backgroundColor: 'rgba(255,255,255,0.7)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.06)';
e.currentTarget.style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.2)';
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.7)';
e.currentTarget.style.transform = 'scale(1)';
}}
>
{uploading ? (
<LoadingOutlined style={{ fontSize: 18, color: '#6366f1' }} />
) : (
<PlusOutlined style={{ fontSize: 18, color: '#64748b' }} />
)}
</div>
</Tooltip>
</Upload>
{/* 上传按钮 - 首尾帧模式下隐藏 */}
{!(mediaType === 'video' && referenceMode === 'first_last_frame') && (
<Upload
accept={mediaType === 'image' ? 'image/*' : 'image/*,video/*'}
showUploadList={false}
beforeUpload={handleUpload}
>
<Tooltip title={mediaType === 'image'
? `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount}`
: `图片${currentMedia.filter(m => m.type === 'image').length}/${maxImageCount},视频${currentMedia.filter(m => m.type === 'video').length}/${maxVideoCount}`
}>
<div
style={{
width: 50,
height: 50,
borderRadius: 14,
border: '2px dashed rgba(99, 102, 241, 0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.25s ease',
flexShrink: 0,
backgroundColor: 'rgba(255,255,255,0.7)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.06)';
e.currentTarget.style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.2)';
e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.7)';
e.currentTarget.style.transform = 'scale(1)';
}}
>
{uploading ? (
<LoadingOutlined style={{ fontSize: 20, color: '#6366f1' }} />
) : (
<PlusOutlined style={{ fontSize: 20, color: '#64748b' }} />
)}
</div>
</Tooltip>
</Upload>
)}
{/* 文本输入框 */}
<TextArea
@@ -1814,7 +2155,9 @@ const AIChatPage: React.FC = () => {
onKeyPress={handleKeyPress}
placeholder={mediaType === 'image'
? `上传最多${maxImageCount}张参考图,输入提示词描述您想生成的画面...`
: `上传参考图(最多${maxImageCount}张)和视频(最多${maxVideoCount}个),输入提示词描述您想生成的画面...`
: referenceMode === 'first_last_frame'
? '上传首帧图片(必填)和尾帧图片(可选),输入提示词描述您想生成的视频...'
: `上传参考图(最多${maxImageCount}张)和视频(最多${maxVideoCount}个),输入提示词描述您想生成的画面...`
}
autoSize={{ minRows: 1, maxRows: 4 }}
style={{
@@ -1824,15 +2167,16 @@ const AIChatPage: React.FC = () => {
border: 'none',
outline: 'none',
boxShadow: 'none',
fontSize: 14,
lineHeight: 1.5,
fontSize: 15,
lineHeight: 1.6,
color: '#1e293b',
fontWeight: 400,
}}
disabled={loading}
/>
{/* @ 提及下拉列表 */}
{mentionVisible && currentMedia.length > 0 && (
{mentionVisible && currentMedia.length > 0 && referenceMode === 'universal' && (
<div
style={{
position: 'absolute',
@@ -1891,18 +2235,30 @@ const AIChatPage: React.FC = () => {
shape="circle"
icon={<ArrowUpOutlined />}
onClick={handleSend}
disabled={!inputValue.trim() && currentMedia.length === 0}
disabled={
mediaType === 'video' && referenceMode === 'first_last_frame'
? !inputValue.trim() && !firstFrame
: !inputValue.trim() && currentMedia.length === 0
}
loading={loading}
style={{
flexShrink: 0,
width: 40,
height: 40,
width: 44,
height: 44,
borderRadius: 14,
background: (inputValue.trim() || currentMedia.length > 0)
? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'
background: (
mediaType === 'video' && referenceMode === 'first_last_frame'
? (inputValue.trim() || firstFrame)
: (inputValue.trim() || currentMedia.length > 0)
)
? 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)'
: '#c7cfdaff',
boxShadow: (inputValue.trim() || currentMedia.length > 0)
? '0 4px 16px rgba(99, 102, 241, 0.4)'
boxShadow: (
mediaType === 'video' && referenceMode === 'first_last_frame'
? (inputValue.trim() || firstFrame)
: (inputValue.trim() || currentMedia.length > 0)
)
? '0 4px 16px rgba(99, 102, 241, 0.4)'
: 'none',
transition: 'all 0.2s ease',
border: 'none',
@@ -2089,6 +2445,170 @@ const AIChatPage: React.FC = () => {
)}
</div>
{/* 参考模式切换按钮 - 仅视频模式显示 */}
{mediaType === 'video' && (supportsFirstLastFrame || supportsUniversalReference) && (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={() => setReferenceModeDropdownVisible(!referenceModeDropdownVisible)}
className="image-settings-trigger"
style={{
minWidth: 120,
padding: '6px 14px',
height: 34,
borderRadius: 10,
border: 'none',
backgroundColor: '#f1f5f9',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
transition: 'all 0.2s',
boxShadow: '0 2px 8px rgba(99, 102, 241, 0.04)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = '0 4px 12px rgba(99, 102, 241, 0.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = '0 2px 8px rgba(99, 102, 241, 0.04)';
}}
>
<PictureOutlined style={{ fontSize: 14, color: '#6366f1' }} />
<Text style={{
fontSize: 14,
fontWeight: 500,
color: '#6366f1',
}}>
{referenceMode === 'universal' ? '全能参考' : '首尾帧'}
</Text>
<CaretDownOutlined style={{ fontSize: 10, color: '#6366f1', marginLeft: 2 }} />
</button>
{referenceModeDropdownVisible && (
<>
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9998,
}}
onClick={() => setReferenceModeDropdownVisible(false)}
/>
<div
className="image-settings-popover"
style={{
position: 'absolute',
bottom: 'calc(100% + 8px)',
left: 0,
minWidth: 160,
backgroundColor: 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)',
borderRadius: 12,
boxShadow: '0 12px 48px rgba(99, 102, 241, 0.15)',
padding: 8,
border: '1px solid rgba(99, 102, 241, 0.1)',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => handleReferenceModeChange('universal')}
disabled={!supportsUniversalReference}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: 8,
border: referenceMode === 'universal'
? '1px solid rgba(99, 102, 241, 0.3)'
: '1px solid transparent',
backgroundColor: referenceMode === 'universal'
? 'rgba(99, 102, 241, 0.08)'
: 'transparent',
cursor: supportsUniversalReference ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
gap: 8,
transition: 'all 0.2s',
textAlign: 'left',
opacity: supportsUniversalReference ? 1 : 0.4,
}}
onMouseEnter={(e) => {
if (supportsUniversalReference && referenceMode !== 'universal') {
e.currentTarget.style.backgroundColor = 'rgba(99, 102, 241, 0.04)';
}
}}
onMouseLeave={(e) => {
if (referenceMode !== 'universal') {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<PictureOutlined style={{ fontSize: 14, color: '#6366f1' }} />
<span style={{
fontSize: 13,
fontWeight: referenceMode === 'universal' ? 600 : 500,
color: referenceMode === 'universal' ? '#6366f1' : '#4b5563',
flex: 1,
}}>
</span>
{!supportsUniversalReference && (
<span style={{ fontSize: 10, color: '#9ca3af' }}></span>
)}
</button>
<button
onClick={() => handleReferenceModeChange('first_last_frame')}
disabled={!supportsFirstLastFrame}
style={{
width: '100%',
padding: '10px 14px',
borderRadius: 8,
border: referenceMode === 'first_last_frame'
? '1px solid rgba(139, 92, 246, 0.3)'
: '1px solid transparent',
backgroundColor: referenceMode === 'first_last_frame'
? 'rgba(139, 92, 246, 0.08)'
: 'transparent',
cursor: supportsFirstLastFrame ? 'pointer' : 'not-allowed',
display: 'flex',
alignItems: 'center',
gap: 8,
transition: 'all 0.2s',
textAlign: 'left',
opacity: supportsFirstLastFrame ? 1 : 0.4,
}}
onMouseEnter={(e) => {
if (supportsFirstLastFrame && referenceMode !== 'first_last_frame') {
e.currentTarget.style.backgroundColor = 'rgba(139, 92, 246, 0.04)';
}
}}
onMouseLeave={(e) => {
if (referenceMode !== 'first_last_frame') {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<SwapOutlined style={{ fontSize: 14, color: '#8b5cf6' }} />
<span style={{
fontSize: 13,
fontWeight: referenceMode === 'first_last_frame' ? 600 : 500,
color: referenceMode === 'first_last_frame' ? '#8b5cf6' : '#4b5563',
flex: 1,
}}>
</span>
{!supportsFirstLastFrame && (
<span style={{ fontSize: 10, color: '#9ca3af' }}></span>
)}
</button>
</div>
</>
)}
</div>
)}
{/* 图片设置按钮 */}
{mediaType === 'image' && (
<div style={{ position: 'relative', display: 'inline-block' }}>