优化全能创作
This commit is contained in:
+180
-50
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user