优化全能创作

This commit is contained in:
Azmat@qq.com
2026-09-11 19:11:27 +08:00
parent dbcac8a44b
commit 02e5dbfb60
25 changed files with 2398 additions and 462 deletions
+180 -50
View File
@@ -1,9 +1,11 @@
import logging
import threading
import re
import uuid
from django.conf import settings
from django.db import transaction
from django.http import JsonResponse, StreamingHttpResponse
from django.http import JsonResponse
from django.db.models import Count, Exists, OuterRef, Q
from django.utils import timezone
from rest_framework import status
@@ -16,23 +18,30 @@ from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from apps.assets.models import Asset
from apps.assets.serializers import AssetFileSerializer, AssetSerializer
from apps.common.api import ServerSentEventRenderer, TeamScopedViewSetMixin, get_current_team
from apps.common.api import TeamScopedViewSetMixin, get_current_team
from apps.common.celery_health import require_worker, require_worker_task
from apps.products.models import Product
from .generation_errors import classify_generation_error, public_error_for_task
from .creation import append_message, sync_generating_messages
from .creation import (
AGENT_BUSY_DETAIL,
append_message,
begin_agent_planning,
cleanup_stale_agent_planning,
finish_agent_planning,
request_agent_cancel,
sync_generating_messages,
team_agent_busy,
)
from .creation_agent import (
_ASSET_CARD_LABELS,
_message_payload,
_sse,
apply_confirm_params,
apply_session_params,
is_greeting,
stream_creation_agent,
submit_confirmed_image,
submit_confirmed_video,
)
from .tasks import run_creation_agent_turn_task
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig
from .serializers import (
@@ -1327,6 +1336,24 @@ class FreeVideoUploadView(APIView):
)
thumb_url = storage.public_url(object_key=poster_key)
# 上传即送审:通过后再入库并返回链接,避免出片时才把「已提交审核」塞进对话。
from apps.assets.review import wait_upload_review
review_status = wait_upload_review(asset)
asset.refresh_from_db()
if review_status == "failed":
detail = (asset.review_error or "素材未通过审核,请换一张图再试").strip()
return Response({"detail": detail, "review_status": "failed", "asset_id": str(asset.id)}, status=status.HTTP_400_BAD_REQUEST)
if review_status == "processing":
return Response(
{"detail": "素材还在审核中,请稍后再试", "review_status": "processing", "asset_id": str(asset.id)},
status=status.HTTP_408_REQUEST_TIMEOUT,
)
# active / allowed:进库,可供引用
if not asset.in_library:
asset.in_library = True
asset.save(update_fields=["in_library", "updated_at"])
return Response(
{
"asset_id": str(asset.id),
@@ -1337,6 +1364,8 @@ class FreeVideoUploadView(APIView):
"width": width,
"height": height,
"thumb_url": thumb_url or (url if kind == "image" else ""),
"review_status": "active" if review_status in ("active", "allowed") else review_status,
"in_library": True,
},
status=status.HTTP_201_CREATED,
)
@@ -1383,6 +1412,11 @@ class ModelConfigViewSet(ReadOnlyModelViewSet):
def _agent_busy_response():
return JsonResponse({"detail": AGENT_BUSY_DETAIL}, status=409)
class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"""全能创作会话 CRUD(契约 §3)。
@@ -1403,6 +1437,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
return CreationConversationDetailSerializer
return super().get_serializer_class()
def create(self, request, *args, **kwargs):
# 离开「整理方案」页再新建会话也不行 —— 团队同时只能有一个 agent turn
team = self.get_team()
if team_agent_busy(team):
return _agent_busy_response()
return super().create(request, *args, **kwargs)
def get_queryset(self):
queryset = super().get_queryset().filter(is_deleted=False, purged_at__isnull=True)
if self.action == "retrieve":
@@ -1442,6 +1483,12 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
def retrieve(self, request, *args, **kwargs):
conversation = self._synced_conversation()
# poll 也会打 retrieve;顺手清超时 planning,避免 worker 挂掉后前端无限「整理方案」
team = getattr(conversation, "team", None)
if team is not None:
cleared = cleanup_stale_agent_planning(team)
if cleared:
conversation.refresh_from_db()
serializer = self.get_serializer(conversation)
return Response(serializer.data)
@@ -1458,18 +1505,43 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
raise ValidationError({"detail": "after_seq 必须是整数"}) from exc
return Response(CreationMessageSerializer(queryset, many=True).data)
@action(
detail=True, methods=["post"], url_path="send",
renderer_classes=[ServerSentEventRenderer],
)
@action(detail=True, methods=["post"], url_path="cancel")
def cancel(self, request, pk=None):
"""终止进行中的整理方案(agent planning)。
仅 `agent_status===planning` 可取消 → **200** `{conversation_id, agent_status: idle}`。
非 planning → **409**。打 Redis 取消标供 Celery 在工具轮间隙停跑,立刻释放
`omni:agent:{team_id}` 锁;已落库消息保留。不影响 confirm 出片/出图。
"""
conversation = self.get_object()
if conversation.agent_status != CreationConversation.AgentStatus.PLANNING:
return JsonResponse(
{"detail": "当前没有正在整理的方案可终止"},
status=409,
)
if not request_agent_cancel(conversation):
return JsonResponse(
{"detail": "当前没有正在整理的方案可终止"},
status=409,
)
return JsonResponse(
{
"conversation_id": str(conversation.id),
"agent_status": CreationConversation.AgentStatus.IDLE,
},
status=200,
)
@action(detail=True, methods=["post"], url_path="send")
def send(self, request, pk=None):
"""发一条消息 → SSE 流(契约 §3)。
"""发一条消息(契约 §3)。
kind=text 普通发言,text + refs
kind=elicit_answer 回答追问卡,reply_to + answers
kind=confirm 点确认闸门 → **不跑模型**,直接按方案卡存的 video_prompt 出片
kind=text / elicit_answer → 入队 Celery 跑 agent,立刻 **202**
`{conversation_id, agent_status}`;前端靠 poll 拉消息。
kind=confirm → 同步 JSON 201(出片/出图),与 agent 编排无关。
闸门「发列表」等短路径 → 同步 JSON 200(已落库消息),不占 agent 锁。
响应 text/event-stream。**必须挂 ServerSentEventRenderer,否则 DRF 内容协商直接 406。**
团队同时只允许一个 planning;冲突 **409** + 中文 detail。
"""
conversation = self.get_object()
# 模型/比例/分辨率/时长在新建会话时锁定,发送和确认出片都按当时那套,
@@ -1520,17 +1592,16 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
},
)
def _gate_pick_stream():
yield _sse({"type": "message", "message": _message_payload(user_msg)})
yield _sse({"type": "message", "message": _message_payload(pick)})
yield _sse({"type": "done"})
response = StreamingHttpResponse(
_gate_pick_stream(), content_type="text/event-stream"
)
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [
CreationMessageSerializer(user_msg).data,
CreationMessageSerializer(pick).data,
],
}, status=200)
# 2. 用户说「你帮我选/你定/随便」
elif re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行|你挑)", text):
@@ -1675,6 +1746,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
# 出片没提交成功 → 把闸门放回去,用户可以改完再确认
card.payload = {**(card.payload or {}), "submitted": False}
card.save(update_fields=["payload", "updated_at"])
# 审核类提示只走 toast,不要落成对话气泡(上传时应已审完)
reviewish = any(
key in error
for key in ("审核", "已提交审核", "通过后即可引用", "上传素材")
)
if reviewish:
return JsonResponse({"detail": error, "message": None}, status=400)
failure = append_message(
conversation, role="assistant",
kind=CreationMessage.Kind.ERROR, text=error,
@@ -1731,16 +1809,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
},
)
def _gate_send_stream():
yield _sse({"type": "message", "message": _message_payload(pick)})
yield _sse({"type": "done"})
response = StreamingHttpResponse(
_gate_send_stream(), content_type="text/event-stream"
)
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
return JsonResponse({
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(pick).data],
}, status=200)
elif choice == "auto":
pending = payload.get("pending_fields") or []
primary_field = pending[0] if pending else {}
@@ -1792,7 +1867,14 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
elif not text and not refs:
return JsonResponse({"detail": "消息不能为空"}, status=400)
model_config = None
# 同一账号同时只允许一个整理方案 turn(含本会话已在 planning 的二次发送)
if (
conversation.agent_status == CreationConversation.AgentStatus.PLANNING
or team_agent_busy(conversation.team)
):
return _agent_busy_response()
model_config_id = None
requested = request.data.get("model_config_id")
if requested:
model_config = (
@@ -1800,21 +1882,69 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
.filter(id=requested, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE)
.first()
)
if model_config is not None:
model_config_id = str(model_config.id)
stream = stream_creation_agent(
conversation=conversation,
user=request.user,
text=text,
refs=refs,
model_config=model_config,
record_user_message=record_user_message,
force_creative_turn=force_creative_turn,
continuation_instruction=continuation_instruction,
turn_kwargs = {
"conversation_id": str(conversation.id),
"user_id": str(request.user.id),
"text": text,
"refs": refs,
"model_config_id": model_config_id,
"record_user_message": record_user_message,
"force_creative_turn": force_creative_turn,
"continuation_instruction": continuation_instruction,
}
inline = bool(getattr(settings, "CREATION_AGENT_INLINE", False))
queue = (getattr(settings, "CREATION_AGENT_TASK_QUEUE", "celery") or "celery").strip() or "celery"
if not inline:
# 共用远程 broker 时 broadcast inspect 常只回 K8s 节点(未注册本任务) → 误 503。
# 本机专用队列 airshelf.local 不依赖「远程已注册」闸;仍靠本机 worker -Q 保证消费。
if queue in {"celery", "airshelf.quick"}:
require_worker_task("apps.ai.tasks.run_creation_agent_turn_task")
if not begin_agent_planning(conversation):
return _agent_busy_response()
try:
if inline:
# 本机 inline:不入共享 broker,避免远程 K8s worker 偷走未注册任务
def _run_inline(kwargs=turn_kwargs):
try:
from apps.ai.creation_agent import run_creation_agent_turn
run_creation_agent_turn(**kwargs)
except Exception: # noqa: BLE001 — 线程内必须自收,否则永久 planning
logger.exception("creation agent inline turn failed")
try:
from apps.ai.models import CreationConversation
from apps.ai.creation import finish_agent_planning as _finish
conv = CreationConversation.objects.filter(
id=kwargs["conversation_id"]
).first()
if conv is not None:
_finish(conv, awaiting_user=False)
except Exception: # noqa: BLE001
logger.exception("creation agent inline: failed to finish planning")
transaction.on_commit(
lambda: threading.Thread(
target=_run_inline, name="creation-agent-inline", daemon=True
).start()
)
else:
run_creation_agent_turn_task.apply_async(kwargs=turn_kwargs, queue=queue)
except Exception:
# 入队失败要把锁和 planning 态收回,否则整团队卡死
finish_agent_planning(conversation, awaiting_user=False)
raise
return JsonResponse(
{
"conversation_id": str(conversation.id),
"agent_status": CreationConversation.AgentStatus.PLANNING,
},
status=202,
)
response = StreamingHttpResponse(stream, content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no" # 关 nginx 缓冲,保证逐帧下发
return response
class MentionSearchView(APIView):