优化全能创作

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
+186
View File
@@ -5,6 +5,9 @@
"""
from __future__ import annotations
from datetime import timedelta
from django.core.cache import cache
from django.db import transaction
from django.db.models import Max
from django.utils import timezone
@@ -201,3 +204,186 @@ def pin_refs(conversation: CreationConversation, refs: list[dict]) -> list[dict]
conversation.pinned_refs = merged
conversation.save(update_fields=["pinned_refs", "updated_at"])
return merged
# ---------------------------------------------------------------- Agent 在途锁(一账号同时只整理一份方案)
AGENT_LOCK_TTL_SECONDS = 20 * 60
AGENT_STALE_MINUTES = 20
AGENT_BUSY_DETAIL = "当前已有对话正在整理方案,请等待完成后再试"
def agent_lock_key(team_id) -> str:
return f"omni:agent:{team_id}"
def release_agent_lock(team_id, conversation_id=None) -> None:
"""释放团队级 agent 锁。传 conversation_id 时只在锁仍指向该会话时删,避免误伤。"""
key = agent_lock_key(team_id)
if conversation_id is None:
cache.delete(key)
return
if str(cache.get(key) or "") == str(conversation_id):
cache.delete(key)
def acquire_agent_lock(team_id, conversation_id) -> bool:
"""cache.add 原子占坑。成功返回 True;已有占用返回 False。"""
return bool(cache.add(agent_lock_key(team_id), str(conversation_id), timeout=AGENT_LOCK_TTL_SECONDS))
def cleanup_stale_agent_planning(team) -> int:
"""把超时仍卡在 planning 的会话清回 idle,并尝试释放对应 Redis 锁。"""
from django.db.models import Q
cutoff = timezone.now() - timedelta(minutes=AGENT_STALE_MINUTES)
stale = list(
CreationConversation.objects.filter(
team=team,
agent_status=CreationConversation.AgentStatus.PLANNING,
is_deleted=False,
).filter(
# started_at 为空也当过期(异常写入)
Q(agent_started_at__lt=cutoff) | Q(agent_started_at__isnull=True)
)[:20]
)
cleared = 0
now = timezone.now()
for conv in stale:
updated = CreationConversation.objects.filter(
pk=conv.pk,
agent_status=CreationConversation.AgentStatus.PLANNING,
).update(
agent_status=CreationConversation.AgentStatus.IDLE,
agent_started_at=None,
updated_at=now,
)
if updated:
release_agent_lock(team.id, conv.id)
cleared += 1
return cleared
def find_planning_conversation(team, *, exclude_id=None):
"""返回团队里仍在 planning 的会话(已先清过期)。"""
cleanup_stale_agent_planning(team)
qs = CreationConversation.objects.filter(
team=team,
agent_status=CreationConversation.AgentStatus.PLANNING,
is_deleted=False,
)
if exclude_id is not None:
qs = qs.exclude(pk=exclude_id)
return qs.order_by("-agent_started_at", "-last_active_at").first()
def team_agent_busy(team, *, exclude_id=None) -> bool:
"""DB planning 或 Redis 锁任一占用 → 忙。exclude_id 用于同一会话续跑自检。"""
if find_planning_conversation(team, exclude_id=exclude_id) is not None:
return True
holder = cache.get(agent_lock_key(team.id))
if not holder:
return False
if exclude_id is not None and str(holder) == str(exclude_id):
return False
# 锁还在但持有会话已不在 planning → 孤儿锁,清掉放行
still = CreationConversation.objects.filter(
id=holder,
team=team,
agent_status=CreationConversation.AgentStatus.PLANNING,
is_deleted=False,
).exists()
if not still:
release_agent_lock(team.id, holder)
return False
return True
def begin_agent_planning(conversation: CreationConversation) -> bool:
"""占 Redis 锁并把会话标成 planning。失败(冲突)返回 False,调用方回 409。"""
team_id = conversation.team_id
if team_agent_busy(conversation.team, exclude_id=conversation.id):
return False
if not acquire_agent_lock(team_id, conversation.id):
# 锁被别人占了;若锁指向自己(重入),允许续写状态
holder = cache.get(agent_lock_key(team_id))
if str(holder or "") != str(conversation.id):
return False
now = timezone.now()
CreationConversation.objects.filter(pk=conversation.pk).update(
agent_status=CreationConversation.AgentStatus.PLANNING,
agent_started_at=now,
last_active_at=now,
updated_at=now,
)
conversation.agent_status = CreationConversation.AgentStatus.PLANNING
conversation.agent_started_at = now
conversation.last_active_at = now
clear_agent_cancel(conversation.id)
return True
def finish_agent_planning(
conversation: CreationConversation,
*,
awaiting_user: bool = False,
) -> None:
"""Celery finally:写回 agent_status 并释放锁。"""
status = (
CreationConversation.AgentStatus.AWAITING_USER
if awaiting_user
else CreationConversation.AgentStatus.IDLE
)
now = timezone.now()
CreationConversation.objects.filter(pk=conversation.pk).update(
agent_status=status,
agent_started_at=None,
last_active_at=now,
updated_at=now,
)
conversation.agent_status = status
conversation.agent_started_at = None
release_agent_lock(conversation.team_id, conversation.id)
# 正常结束也清掉取消标记,避免下一轮误判
clear_agent_cancel(conversation.id)
def agent_cancel_key(conversation_id) -> str:
return f"omni:agent:cancel:{conversation_id}"
def clear_agent_cancel(conversation_id) -> None:
cache.delete(agent_cancel_key(conversation_id))
def is_agent_cancel_requested(conversation_id) -> bool:
"""Celery tool round 之间读:用户点了终止则 True。"""
return bool(cache.get(agent_cancel_key(conversation_id)))
def request_agent_cancel(conversation: CreationConversation) -> bool:
"""用户终止整理方案:打 Redis 取消标,立刻 idle + 释放团队锁。
仅当当前仍是 planning 时成功。Celery 在下一轮工具间隙看到标后停跑,
已落库消息保留。返回 True=已受理, False=当时不是 planning(调用方回 409)。
"""
# 先打标,再收态 —— worker 在长 LLM 调用回来后也能看见
cache.set(agent_cancel_key(conversation.id), "1", timeout=AGENT_LOCK_TTL_SECONDS)
updated = CreationConversation.objects.filter(
pk=conversation.pk,
agent_status=CreationConversation.AgentStatus.PLANNING,
).update(
agent_status=CreationConversation.AgentStatus.IDLE,
agent_started_at=None,
last_active_at=timezone.now(),
updated_at=timezone.now(),
)
if not updated:
# 已经不是 planning(竞态完成/别人清了);仍留标一会儿无害,清掉避免脏状态
clear_agent_cancel(conversation.id)
conversation.refresh_from_db(fields=["agent_status", "agent_started_at"])
return False
conversation.agent_status = CreationConversation.AgentStatus.IDLE
conversation.agent_started_at = None
release_agent_lock(conversation.team_id, conversation.id)
return True
+360 -31
View File
@@ -548,6 +548,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"name": "write_strategy",
"description": (
"写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
"四个字段都必须写具体非空文案,禁止空字符串。"
"仅当用户明确要做片/出方案时,在 write_plan 之前调一次;打招呼或闲聊不要调。"
),
"parameters": {
@@ -569,6 +570,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"description": (
"写「视频最终方案」卡并请用户确认。**仅当用户明确要做片/出方案/改方案时调用**;"
"打招呼或闲聊不要调。调完会等用户点确认,确认后平台直接按 video_prompt 出片。"
"usp / points / timeline 是给用户看的卡片正文,必须写满具体文案,禁止空着只交 video_prompt。"
"video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜(专业创作同口径),不要只写大纲。"
"先调 write_strategy 再调它。"
),
@@ -607,7 +609,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
),
},
},
"required": ["usp", "video_prompt"],
"required": ["usp", "points", "video_prompt"],
},
},
})
@@ -1253,14 +1255,181 @@ def _merge_tool_call_deltas(buffer: dict, deltas: list) -> None:
def _parse_arguments(raw: str) -> dict:
try:
parsed = json.loads(raw or "{}")
except ValueError:
"""解析工具 arguments。模型偶发包 markdown 围栏或夹杂前后缀,尽量救回 JSON。"""
text = (raw or "").strip()
if not text:
return {}
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:].lstrip()
text = text.strip()
try:
parsed = json.loads(text)
except ValueError:
start, end = text.find("{"), text.rfind("}")
if start < 0 or end <= start:
return {}
try:
parsed = json.loads(text[start : end + 1])
except ValueError:
return {}
return parsed if isinstance(parsed, dict) else {}
def stream_creation_agent(
def _pick_str(args: dict, *keys: str) -> str:
"""按候选键取非空字符串;兼容中文别名与嵌套一层 dict。"""
for key in keys:
value = args.get(key)
if isinstance(value, dict):
# 偶发 {"text": "..."} / {"value": "..."}
for nested in ("text", "value", "content", "desc", "description"):
inner = value.get(nested)
if isinstance(inner, str) and inner.strip():
return inner.strip()
continue
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _coerce_points(raw) -> list[str]:
"""points 规整成最多 3 条非空文案。兼容纯字符串、对象数组、dict。"""
if raw is None:
return []
items: list = []
if isinstance(raw, str):
text = raw.strip()
if not text:
return []
# 中文分号/换行拆条
for part in text.replace("\r", "\n").replace("", "\n").split("\n"):
part = part.strip(" ·•-、,,")
if part:
items.append(part)
elif isinstance(raw, dict):
# {"P0": "...", "P1": "..."} 或 {"0": "..."}
for key in sorted(raw.keys(), key=lambda k: str(k)):
val = raw[key]
if isinstance(val, dict):
text = _pick_str(val, "text", "value", "content", "desc", "point", "label")
else:
text = str(val or "").strip()
if text:
items.append(text)
elif isinstance(raw, (list, tuple)):
for item in raw:
if isinstance(item, dict):
text = _pick_str(item, "text", "value", "content", "desc", "point", "label")
else:
text = str(item or "").strip()
if text:
items.append(text)
else:
text = str(raw).strip()
if text:
items.append(text)
# 过滤单字符噪声(字符串被误当成 list 迭代时的残留)
cleaned = [p for p in items if len(p) > 1]
return cleaned[:3]
def _coerce_voice_chars(raw, fallback: list[int] | None = None) -> list[int]:
"""voice_chars 必须是 [下限, 上限];模型常误塞语气文案或单个整数。"""
if isinstance(raw, (list, tuple)) and len(raw) >= 2:
try:
lo, hi = int(raw[0]), int(raw[1])
if lo > 0 and hi >= lo:
return [lo, hi]
except (TypeError, ValueError):
pass
if isinstance(raw, (int, float)) and int(raw) > 0:
n = int(raw)
return [max(1, n - 5), n + 5]
return list(fallback or [])
def _coerce_timeline(raw) -> list[dict]:
items: list[dict] = []
for item in (raw or []) if isinstance(raw, list) else []:
if not isinstance(item, dict):
continue
try:
start = float(item.get("start"))
end = float(item.get("end"))
except (TypeError, ValueError):
continue
stage = str(item.get("stage") or "").strip()
if not stage:
continue
entry = {"start": start, "end": end, "stage": stage}
desc = str(item.get("desc") or item.get("description") or "").strip()
if desc:
entry["desc"] = desc
items.append(entry)
return items
def _default_plan_matrix(usp: str, points: list[str]) -> dict:
"""设计稿里的「卖点覆盖矩阵」:有 USP/支撑点就自动铺一版,避免方案卡干瘪。"""
rows = [{"point": "主打卖点 USP", "hits": [1, 3]}]
labels = ["体验卖点 P0", "视觉卖点 P0", "转化卖点 P0"]
for index, point in enumerate(points[:3]):
label = labels[index] if index < len(labels) else f"支撑卖点 P{index}"
# 点名用文案前缀,hits 错落分布到 4 镜
hit = (index % 4) + 1
rows.append({"point": label if not point else f"{label}", "hits": [hit]})
return {"shots": 4, "rows": rows}
def _coerce_strategy_args(args: dict) -> dict:
return {
"target": _pick_str(
args, "target", "audience", "who", "给谁看", "目标人群", "人群",
),
"trust": _pick_str(
args, "trust", "credibility", "为什么相信", "信任", "可信度",
),
"belief": _pick_str(
args, "belief", "希望相信", "想让他信什么", "认知", "takeaway",
),
"direction": _pick_str(
args, "direction", "创作方向", "方向", "style", "路线",
),
}
def _coerce_plan_card_args(args: dict) -> dict:
usp = _pick_str(args, "usp", "主打卖点", "卖点", "core_usp", "main_point")
points = _coerce_points(
args.get("points")
if args.get("points") is not None
else args.get("支撑点") or args.get("supports") or args.get("selling_points")
)
# 兼容 point1/point2/point3 展开写法(设计稿 blueprint)
if not points:
for key in ("point1", "point2", "point3", "P0", "P1", "P2"):
text = _pick_str(args, key)
if text:
points.append(text)
points = points[:3]
timeline = _coerce_timeline(args.get("timeline") or args.get("时间轴"))
matrix = args.get("matrix")
if not isinstance(matrix, dict) or not matrix.get("rows"):
matrix = _default_plan_matrix(usp, points) if (usp or points) else {}
return {
"usp": usp,
"points": points,
"timeline": timeline,
"matrix": matrix,
"voice_chars": args.get("voice_chars"),
}
def iter_creation_agent_events(
*,
conversation: CreationConversation,
user,
@@ -1270,15 +1439,15 @@ def stream_creation_agent(
record_user_message: bool = True,
force_creative_turn: bool = False,
continuation_instruction: str = "",
) -> Iterator[str]:
"""一条用户消息 → SSE 流。生成器,由 StreamingHttpResponse 逐帧下发"""
) -> Iterator[dict]:
"""一条用户消息 → 事件 dict 流(message/delta/tool/done/error)。消息已落库"""
refs = refs or []
fast_greeting = record_user_message and is_greeting(text)
# 打招呼不依赖模型,模型未配置也能即时回应;其他消息保持原有的先校验模型行为。
if not fast_greeting:
model_config = get_creation_chat_model(model_config)
if model_config is None:
yield _sse({"type": "error", "detail": "没有可用的文本模型,请先在模型库配置"})
yield {"type": "error", "detail": "没有可用的文本模型,请先在模型库配置"}
return
context = AgentContext(conversation=conversation, user=user, model_config=model_config)
@@ -1289,7 +1458,7 @@ def stream_creation_agent(
if record_user_message:
user_message = append_message(conversation, role="user", text=text, refs=refs)
if user_message is not None:
yield _sse({"type": "message", "message": _message_payload(user_message)})
yield {"type": "message", "message": _message_payload(user_message)}
# 单纯打招呼无论有没有参考素材、有没有既有上下文,都不值得等模型。
# 素材先收下,等用户说清用途再分析,避免「你好」卡住还冒出一大段建议。
if fast_greeting:
@@ -1301,8 +1470,8 @@ def stream_creation_agent(
if refs else "你好。想做什么,直接说就行。"
),
)
yield _sse({"type": "message", "message": _message_payload(reply)})
yield _sse({"type": "done"})
yield {"type": "message", "message": _message_payload(reply)}
yield {"type": "done"}
return
# 空会话里的简短应答:短回一句就够。已有商品/方向时走模型,
@@ -1314,8 +1483,8 @@ def stream_creation_agent(
role="assistant",
text="我在。想做什么,直接丢一句想法给我就行。",
)
yield _sse({"type": "message", "message": _message_payload(reply)})
yield _sse({"type": "done"})
yield {"type": "message", "message": _message_payload(reply)}
yield {"type": "done"}
return
model_config = _prefer_vision_text_model(model_config, conversation.team, conversation.pinned_refs or [])
@@ -1328,7 +1497,7 @@ def stream_creation_agent(
conversation, role="assistant",
text=f"有几个引用的素材已经找不到了({names}),我先按其余信息继续。",
)
yield _sse({"type": "message", "message": _message_payload(note)})
yield {"type": "message", "message": _message_payload(note)}
# 压缩放在**建消息之前**:摘要要进这一轮的 system 提示词才有意义。
# 用户消息已经先回显了,所以这一小段等待不会看起来像卡住。
@@ -1347,7 +1516,13 @@ def stream_creation_agent(
messages.append({"role": "user", "content": continuation_instruction.strip()})
tools = tool_schemas(context, allow_plan=allow_plan)
from .creation import is_agent_cancel_requested
for _round in range(MAX_TOOL_ROUNDS):
if is_agent_cancel_requested(conversation.id):
# 用户终止:干净收束,不落 ERROR,已落库消息保留
yield {"type": "cancelled"}
return
text_buffer: list[str] = []
tool_buffer: dict = {}
for chunk in provider.chat_completion_stream(
@@ -1358,11 +1533,11 @@ def stream_creation_agent(
):
kind = chunk.get("type")
if kind == "reasoning":
yield _sse({"type": "reasoning", "text": chunk.get("text", "")})
yield {"type": "reasoning", "text": chunk.get("text", "")}
elif kind == "delta":
piece = chunk.get("text", "")
text_buffer.append(piece)
yield _sse({"type": "delta", "text": piece})
yield {"type": "delta", "text": piece}
elif kind == "tool_call":
_merge_tool_call_deltas(tool_buffer, chunk.get("tool_calls"))
@@ -1407,7 +1582,7 @@ def stream_creation_agent(
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
if said and not asks_user:
bubble = append_message(conversation, role="assistant", text=said)
yield _sse({"type": "message", "message": _message_payload(bubble)})
yield {"type": "message", "message": _message_payload(bubble)}
if not calls:
if fallback_fields:
@@ -1415,7 +1590,7 @@ def stream_creation_agent(
context, "ask_user", {"fields": fallback_fields}, allow_pick=allow_pick
)
for event in result.get("_events", []):
yield _sse(event)
yield event
break
messages.append({
@@ -1430,23 +1605,26 @@ def stream_creation_agent(
stop = False
for index, call in enumerate(calls):
if is_agent_cancel_requested(conversation.id):
yield {"type": "cancelled"}
return
name = call["name"]
args = _parse_arguments(call["arguments"])
yield _sse({"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "running"})
yield {"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "running"}
try:
result, stop_after = _dispatch_tool(context, name, args)
except AgentError as exc:
yield _sse({"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "error"})
yield {"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "error"}
failure = append_message(
conversation, role="assistant",
kind=CreationMessage.Kind.ERROR, text=str(exc),
)
yield _sse({"type": "message", "message": _message_payload(failure)})
yield _sse({"type": "done"})
yield {"type": "message", "message": _message_payload(failure)}
yield {"type": "done"}
return
yield _sse({"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "done"})
yield {"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "done"}
for event in result.get("_events", []):
yield _sse(event)
yield event
messages.append({
"role": "tool",
"tool_call_id": f"call_{index}",
@@ -1456,10 +1634,132 @@ def stream_creation_agent(
if stop:
break
yield _sse({"type": "done"})
yield {"type": "done"}
except Exception as exc: # noqa: BLE001 — SSE 里任何未捕获异常都会变成前端「白屏卡死」
logger.exception("creation agent stream failed: %s", exc)
yield _sse({"type": "error", "detail": "生成过程出错了,请再试一次"})
yield {"type": "error", "detail": "生成过程出错了,请再试一次"}
def stream_creation_agent(
*,
conversation: CreationConversation,
user,
text: str,
refs: list[dict] | None = None,
model_config: ModelConfig | None = None,
record_user_message: bool = True,
force_creative_turn: bool = False,
continuation_instruction: str = "",
) -> Iterator[str]:
"""兼容旧 SSE 消费方(单测 / 调试)。生产路径走 Celery + poll。"""
for event in iter_creation_agent_events(
conversation=conversation,
user=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,
):
yield _sse(event)
def run_creation_agent_turn(
*,
conversation_id: str,
user_id: str,
text: str = "",
refs: list | None = None,
model_config_id: str | None = None,
record_user_message: bool = True,
force_creative_turn: bool = False,
continuation_instruction: str = "",
) -> str:
"""Celery worker 入口:跑完一轮 tool loop,消息落库;更新 agent_status;释放团队锁。
不复用 CreationMessage.kind=generating / AITask —— 那些是确认后出片/出图用的。
"""
from apps.accounts.models import User
from .creation import finish_agent_planning
conversation = (
CreationConversation.objects.select_related("team", "created_by")
.filter(id=conversation_id)
.first()
)
if conversation is None:
return conversation_id
user = User.objects.filter(id=user_id).first() or conversation.created_by
model_config = None
if model_config_id:
model_config = (
ModelConfig.objects.select_related("provider")
.filter(id=model_config_id, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE)
.first()
)
awaiting_user = False
user_cancelled = False
try:
for event in iter_creation_agent_events(
conversation=conversation,
user=user,
text=text or "",
refs=refs or [],
model_config=model_config,
record_user_message=record_user_message,
force_creative_turn=force_creative_turn,
continuation_instruction=continuation_instruction or "",
):
if not isinstance(event, dict):
continue
if event.get("type") == "cancelled":
user_cancelled = True
awaiting_user = False
break
if event.get("type") == "message":
message = event.get("message") or {}
kind = message.get("kind") if isinstance(message, dict) else None
if kind in (CreationMessage.Kind.ELICIT, CreationMessage.Kind.CONFIRM):
awaiting_user = True
elif event.get("type") == "error":
detail = str(event.get("detail") or "生成过程出错了,请再试一次")
# 循环内多数错误已落 ERROR 消息;这里兜底一条,避免前端只看到卡在 planning
last = conversation.messages.order_by("-seq").first()
if last is None or last.kind != CreationMessage.Kind.ERROR:
append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ERROR,
text=detail,
)
except Exception as exc: # noqa: BLE001 — worker 不能把异常冒成无限 planning
logger.exception("creation agent turn failed: %s", exc)
try:
append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ERROR,
text="生成过程出错了,请再试一次",
)
except Exception: # noqa: BLE001
logger.exception("creation agent turn: failed to append error message")
awaiting_user = False
finally:
try:
conversation.refresh_from_db(fields=["agent_status", "team_id"])
except Exception: # noqa: BLE001
pass
# 用户已终止时 API 侧多半已 idle+放锁;这里再收一次保证幂等,且不落入 awaiting_user
finish_agent_planning(
conversation,
awaiting_user=(awaiting_user and not user_cancelled),
)
return conversation_id
_TOOL_LABELS = {
@@ -1579,10 +1879,21 @@ def _dispatch_tool(
return {"payload": _run_search_library(context, args)}, False
if name == "write_strategy":
strategy_payload = _coerce_strategy_args(args if isinstance(args, dict) else {})
# 空卡会落成「只有标签没有正文」——拒绝,让模型把四字段写满再调
if not all(strategy_payload.values()):
return {
"payload": {
"error": (
"创作策略卡四字段都不能为空:请填写具体的 target / trust / belief / direction"
"(给谁看、为什么信、希望他信什么、创作方向),不要留空。"
)
}
}, False
message = append_message(
context.conversation, role="assistant",
kind=CreationMessage.Kind.STRATEGY,
payload={key: str(args.get(key) or "") for key in ("target", "trust", "belief", "direction")},
payload=strategy_payload,
)
# 策略卡只是「我理解对了吗」,不打断 —— 模型接着就该写方案
return {
@@ -1594,11 +1905,29 @@ def _dispatch_tool(
video_prompt = str(args.get("video_prompt") or "").strip()
if not video_prompt:
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
card = _coerce_plan_card_args(args if isinstance(args, dict) else {})
if not card["usp"] or not card["points"]:
return {
"payload": {
"error": (
"方案卡缺正文:请填写非空的 usp(主打卖点)和 points(13 条核心支撑),"
"不要只写 video_prompt。卡片上要让用户看见卖点文案。"
)
}
}, False
duration = 15
try:
duration = int(float(str((context.conversation.params or {}).get("duration") or "15").replace("", "").strip() or "15"))
except (TypeError, ValueError):
duration = 15
lo = max(20, round(duration * 3.4))
hi = max(lo + 1, round(duration * 4))
plan_payload = {
"usp": str(args.get("usp") or ""),
"points": [str(p) for p in (args.get("points") or [])][:3],
"timeline": args.get("timeline") or [],
"voice_chars": args.get("voice_chars") or [],
"usp": card["usp"],
"points": card["points"],
"timeline": card["timeline"],
"matrix": card["matrix"],
"voice_chars": _coerce_voice_chars(card["voice_chars"], [lo, hi]),
"ref_count": len(context.conversation.pinned_refs or []),
}
events = []
+12 -7
View File
@@ -135,23 +135,28 @@ def _guard_asset_reference(asset: Asset, label: str) -> None:
平台生成的资产免审直接过;用户上传的必须真过一遍审核才能当生成参考。
判据是 Asset.source,**不是**「在不在资产库里」—— 按库免审等于把审核架空:
用户传一张图进库、再从自由创作引用出去,就绕过了整套人像审核。
未送审时在这里短等审核(上传链路已优先审完);不再甩「已提交审核」话术进对话。
"""
from apps.assets.review import poll_asset_review, reference_review_state, submit_asset_for_review
from apps.assets.review import poll_asset_review, reference_review_state, wait_upload_review
state = reference_review_state(asset)
if state == "processing":
poll_asset_review(asset) # 实时刷一次,别让用户干等下一轮轮询
poll_asset_review(asset)
state = reference_review_state(asset)
if state == "allowed":
return
name = label or asset.name or "未命名"
if state == "processing":
raise ValueError(f"素材「{name}」正在审核中,请稍后再引用")
if state == "failed":
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考")
# 从没送过审(资产库上传不自动送审):这里补送一次,用户等审核结果即可,不必回去手动点
submit_asset_for_review(asset, force=True)
raise ValueError(f"素材「{name}」是上传素材,已提交审核,通过后即可引用")
# unsubmitted / 仍 processing:补送并短等,通过则放行
waited = wait_upload_review(asset, timeout_s=20.0)
if waited in ("active", "allowed"):
return
if waited == "failed":
err = (asset.review_error or "").strip()
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考" + (f"{err}" if err else ""))
raise ValueError(f"素材「{name}」还在审核中,请稍后再试")
def build_content_items(
+3 -3
View File
@@ -66,7 +66,7 @@ def _search_products(team, q: str, limit: int) -> list[dict]:
if q:
queryset = queryset.filter(title__icontains=q)
out = []
for product in queryset.order_by("-created_at")[:limit]:
for product in queryset.order_by("-created_at", "-id")[:limit]:
out.append(_ref("product", product.id, product.title, _product_cover_url(product)))
return out
@@ -76,7 +76,7 @@ def _search_models(team, q: str, limit: int) -> list[dict]:
if q:
queryset = queryset.filter(name__icontains=q)
out = []
for model in queryset.select_related("portrait_asset")[:limit]:
for model in queryset.select_related("portrait_asset").order_by("-created_at")[:limit]:
out.append(_ref("model", model.id, model.name, _asset_preview_url(model.portrait_asset)))
return out
@@ -95,7 +95,7 @@ def _search_assets(team, q: str, limit: int, categories: tuple[str, ...], type_:
if q:
queryset = queryset.filter(name__icontains=q)
out = []
for asset in queryset.order_by("-created_at")[:limit]:
for asset in queryset.order_by("-created_at", "-id")[:limit]:
out.append(_ref(type_, asset.id, asset.name, _asset_preview_url(asset)))
return out
@@ -0,0 +1,35 @@
# Generated manually for omni agent async planning
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ai", "0036_disable_deepseek_text"),
]
operations = [
migrations.AddField(
model_name="creationconversation",
name="agent_status",
field=models.CharField(
choices=[
("idle", "空闲"),
("planning", "整理方案中"),
("awaiting_user", "等待用户"),
],
default="idle",
max_length=16,
),
),
migrations.AddField(
model_name="creationconversation",
name="agent_started_at",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddIndex(
model_name="creationconversation",
index=models.Index(fields=["team", "agent_status"], name="ai_creation_team_ag_idx"),
),
]
+11
View File
@@ -300,6 +300,11 @@ class CreationConversation(TeamOwnedModel):
COMPLETED = "completed", "已完成"
FAILED = "failed", "失败"
class AgentStatus(models.TextChoices):
IDLE = "idle", "空闲"
PLANNING = "planning", "整理方案中"
AWAITING_USER = "awaiting_user", "等待用户"
title = models.CharField(max_length=120, default="未命名创作")
mode = models.CharField(max_length=16, choices=Mode.choices, default=Mode.VIDEO)
preset = models.CharField(max_length=64, blank=True, default="") # "" = 自由创作
@@ -310,6 +315,11 @@ class CreationConversation(TeamOwnedModel):
# 记忆:{summary, artifacts:[{msg_id,asset_id,prompt,kind}], turn_count}
memory = models.JSONField(default=dict, blank=True)
status = models.CharField(max_length=16, choices=Status.choices, default=Status.RUNNING)
# Agent 编排态(与 status=会话生命周期正交)。planning 时整团队只能有一个在途 turn。
agent_status = models.CharField(
max_length=16, choices=AgentStatus.choices, default=AgentStatus.IDLE,
)
agent_started_at = models.DateTimeField(null=True, blank=True)
last_active_at = models.DateTimeField(auto_now_add=True)
is_deleted = models.BooleanField(default=False)
purged_at = models.DateTimeField(null=True, blank=True)
@@ -320,6 +330,7 @@ class CreationConversation(TeamOwnedModel):
models.Index(fields=["team", "-last_active_at"]),
models.Index(fields=["team", "status", "-last_active_at"]),
models.Index(fields=["team", "is_deleted", "purged_at"]),
models.Index(fields=["team", "agent_status"]),
]
def __str__(self) -> str:
+3 -1
View File
@@ -119,11 +119,13 @@ class CreationConversationSerializer(serializers.ModelSerializer):
model = CreationConversation
fields = [
"id", "title", "mode", "preset", "params", "status",
"agent_status", "agent_started_at",
"message_count", "cover_url",
"last_active_at", "created_at", "updated_at",
]
read_only_fields = [
"id", "status", "message_count", "cover_url",
"id", "status", "agent_status", "agent_started_at",
"message_count", "cover_url",
"last_active_at", "created_at", "updated_at",
]
+32
View File
@@ -144,3 +144,35 @@ def drain_asset_reviews_task(self) -> str:
drain_asset_reviews_task.apply_async(countdown=45)
return "ok"
@app.task(bind=True, max_retries=0)
def run_creation_agent_turn_task(
self,
conversation_id: str,
user_id: str,
text: str = "",
refs: list | None = None,
model_config_id: str | None = None,
record_user_message: bool = True,
force_creative_turn: bool = False,
continuation_instruction: str = "",
) -> str:
"""全能创作 · Agent 整理方案(tool loop)在 worker 内跑,离开页面也不中断。
失败自收成 ERROR + idle( run_creation_agent_turn), max_retries=0
队列由 CREATION_AGENT_TASK_QUEUE 决定(本地常用 airshelf.local,避免与未部署该任务的
远程 worker 共用 celery 队列时被偷走 NotRegistered / 永久 planning)
"""
from apps.ai.creation_agent import run_creation_agent_turn
return run_creation_agent_turn(
conversation_id=conversation_id,
user_id=str(user_id),
text=text or "",
refs=refs or [],
model_config_id=model_config_id,
record_user_message=record_user_message,
force_creative_turn=force_creative_turn,
continuation_instruction=continuation_instruction or "",
)
+16 -32
View File
@@ -476,7 +476,7 @@ class SendEndpointTests(TestCase):
{"kind": "text", "text": f"就用{product.title}"},
format="json",
)
list(response.streaming_content)
self.assertIn(response.status_code, (200, 202))
question.refresh_from_db()
self.conversation.refresh_from_db()
@@ -521,7 +521,7 @@ class SendEndpointTests(TestCase):
{"kind": "text", "text": "你来定"},
format="json",
)
list(response.streaming_content)
self.assertIn(response.status_code, (200, 202))
question.refresh_from_db()
self.conversation.refresh_from_db()
@@ -570,7 +570,7 @@ class SendEndpointTests(TestCase):
{"kind": "text", "text": "改成 10 秒吧"},
format="json",
)
list(response.streaming_content)
self.assertIn(response.status_code, (200, 202))
question.refresh_from_db()
self.conversation.refresh_from_db()
@@ -591,7 +591,7 @@ class SendEndpointTests(TestCase):
{"kind": "elicit_answer", "reply_to": str(card.id), "answers": {"tone": "warm"}},
format="json",
)
list(response.streaming_content)
self.assertIn(response.status_code, (200, 202))
card.refresh_from_db()
self.assertTrue(card.payload["submitted"])
@@ -616,7 +616,7 @@ class SendEndpointTests(TestCase):
"answers": {"product": str(product.id)}},
format="json",
)
list(response.streaming_content)
self.assertIn(response.status_code, (200, 202))
self.conversation.refresh_from_db()
pinned = self.conversation.pinned_refs or []
@@ -640,7 +640,7 @@ class SendEndpointTests(TestCase):
"answers": {"product": product.title}},
format="json",
)
list(response.streaming_content)
self.assertIn(response.status_code, (200, 202))
self.conversation.refresh_from_db()
pinned = self.conversation.pinned_refs or []
@@ -697,23 +697,14 @@ class SendEndpointTests(TestCase):
"answers": {"_asset_gate": "skip"}},
format="json",
)
events = _events(
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
for chunk in response.streaming_content
)
self.assertEqual(response.status_code, 200)
self.assertIn(response.status_code, (200, 202))
self.assertEqual(self.conversation.messages.filter(role="user").count(), before_users)
self.assertFalse(any(
event.get("type") == "message" and event["message"]["role"] == "user"
for event in events
))
self.assertTrue(fake.calls)
tool_names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]}
self.assertIn("generate_image", tool_names)
self.assertNotIn("有需要再说", "".join(
event["message"].get("text", "")
for event in events if event.get("type") == "message"
message.text for message in self.conversation.messages.filter(role="assistant")
))
def test_gate_emits_conversational_question_not_direct_pick_card(self):
@@ -727,18 +718,15 @@ class SendEndpointTests(TestCase):
{"kind": "text", "text": "我想做一条带货视频"},
format="json",
)
events = _events(
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
for chunk in response.streaming_content
)
elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"]
self.assertIn(response.status_code, (200, 202))
elicit = list(self.conversation.messages.filter(kind=CreationMessage.Kind.ELICIT))
self.assertEqual(len(elicit), 1)
payload = elicit[0]["message"]["payload"]
payload = elicit[0].payload or {}
self.assertEqual(payload["interaction"], "chat")
self.assertEqual(payload["phase"], "gate")
self.assertEqual(payload["fields"][0]["key"], "_asset_gate")
self.assertEqual(payload["pending_fields"][0]["key"], "product")
self.assertIn("商品库列表", elicit[0]["message"]["text"])
self.assertIn("商品库列表", elicit[0].text)
def test_typing_yes_in_chat_triggers_pick_card(self):
"""在「要不要发列表」闸门挂起时,用户回「需要」直接弹出 pick 卡片。"""
@@ -772,20 +760,16 @@ class SendEndpointTests(TestCase):
{"kind": "text", "text": "需要"},
format="json",
)
events = _events(
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
for chunk in response.streaming_content
)
self.assertEqual(response.status_code, 200)
card.refresh_from_db()
self.assertTrue(card.payload["submitted"])
self.assertEqual(card.payload["answers"], {"_asset_gate": "send"})
pick_cards = [
e["message"] for e in events
if e.get("type") == "message" and e["message"]["kind"] == "elicit" and e["message"]["payload"].get("phase") == "pick"
message for message in self.conversation.messages.filter(kind=CreationMessage.Kind.ELICIT)
if (message.payload or {}).get("phase") == "pick"
]
self.assertEqual(len(pick_cards), 1)
self.assertEqual(pick_cards[0]["payload"]["fields"][0]["type"], "asset")
self.assertEqual(pick_cards[0].payload["fields"][0]["type"], "asset")
def test_typing_product_name_in_chat_answers_gate(self):
"""在闸门挂起时,用户直接输入商品名,直接绑定商品并推进。"""
@@ -812,7 +796,7 @@ class SendEndpointTests(TestCase):
{"kind": "text", "text": f"就推{product.title}"},
format="json",
)
list(response.streaming_content)
self.assertIn(response.status_code, (200, 202))
card.refresh_from_db()
self.conversation.refresh_from_db()
@@ -6,7 +6,16 @@ from apps.accounts.models import Team, TeamMember, User
from apps.assets.models import Asset, AssetFile
from .creation import append_message, finish_generating_message, pin_refs, sync_generating_messages
from .creation import (
append_message,
begin_agent_planning,
finish_generating_message,
is_agent_cancel_requested,
pin_refs,
request_agent_cancel,
sync_generating_messages,
team_agent_busy,
)
from .models import AITask, CreationConversation, CreationMessage, ModelConfig, ModelProvider
@@ -236,3 +245,39 @@ class CreationConversationAPITests(TestCase):
conversation.refresh_from_db()
self.assertTrue(conversation.is_deleted)
self.assertEqual(self.client.get(f"/api/ai/creations/{conversation.id}/").status_code, 404)
class CreationAgentCancelTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="omni-cancel", password="p")
self.team = Team.objects.create(name="Omni Cancel", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role="owner")
self.conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, title="取消测试", mode="video"
)
self.client = APIClient()
self.client.force_authenticate(self.user)
def test_request_agent_cancel_releases_lock_and_sets_flag(self):
self.assertTrue(begin_agent_planning(self.conversation))
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.PLANNING)
self.assertTrue(team_agent_busy(self.team))
self.assertTrue(request_agent_cancel(self.conversation))
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.IDLE)
self.assertTrue(is_agent_cancel_requested(self.conversation.id))
self.assertFalse(team_agent_busy(self.team))
def test_cancel_api_only_when_planning(self):
idle = self.client.post(f"/api/ai/creations/{self.conversation.id}/cancel/", {}, format="json")
self.assertEqual(idle.status_code, 409)
self.assertTrue(begin_agent_planning(self.conversation))
ok = self.client.post(f"/api/ai/creations/{self.conversation.id}/cancel/", {}, format="json")
self.assertEqual(ok.status_code, 200, getattr(ok, "data", ok.content))
self.assertEqual(ok.json()["agent_status"], "idle")
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.agent_status, CreationConversation.AgentStatus.IDLE)
self.assertFalse(team_agent_busy(self.team))
@@ -113,14 +113,19 @@ class AssetReferenceBuildTests(TestCase):
built = self._build(asset)
self.assertEqual(built["content_items"][0]["image_url"]["url"], "asset://asset-abc")
def test_unreviewed_upload_is_blocked_and_submitted(self):
def test_unreviewed_upload_waits_and_allows_when_active(self):
asset = _asset(self.team, source=Asset.Source.UPLOAD)
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as submit:
with patch("apps.assets.review.wait_upload_review", return_value="active") as wait:
built = self._build(asset)
wait.assert_called_once()
self.assertEqual(built["image_n"], 1)
def test_unreviewed_upload_still_processing_is_blocked(self):
asset = _asset(self.team, source=Asset.Source.UPLOAD)
with patch("apps.assets.review.wait_upload_review", return_value="processing"):
with self.assertRaises(ValueError) as ctx:
self._build(asset)
self.assertIn("已提交审核", str(ctx.exception))
submit.assert_called_once()
self.assertTrue(submit.call_args.kwargs["force"]) # 上传素材不看类目白名单,一律登记
self.assertIn("还在审核", str(ctx.exception))
def test_failed_review_is_blocked(self):
asset = _asset(self.team, source=Asset.Source.UPLOAD, review_status="failed")
+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):
+25
View File
@@ -4,6 +4,7 @@
全部 best-effort:审核未启用/出错都不影响主流程(生图/采用照常)
"""
import logging
import time
from datetime import timedelta
from django.db import IntegrityError, transaction
@@ -233,6 +234,30 @@ def ensure_review_drain_loop() -> None:
logger.warning("kick asset review drain loop failed", exc_info=True)
def wait_upload_review(asset: Asset, *, timeout_s: float = 45.0, interval_s: float = 1.5) -> str:
"""上传素材:立刻送审并等到终态。
返回 active / failed / processing(超时仍在审) / allowed(审核未启用)
审核未配置时不能拦上传直接当 allowed
"""
if not assets_client.is_enabled():
return "allowed"
submit_asset_for_review(asset, force=True)
asset.refresh_from_db()
status = asset.review_status or ""
if status in ("active", "failed"):
return status
deadline = time.monotonic() + max(3.0, float(timeout_s))
while time.monotonic() < deadline:
status = poll_asset_review(asset) or ""
if status in ("active", "failed"):
return status
time.sleep(max(0.4, float(interval_s)))
asset.refresh_from_db()
return asset.review_status or "processing"
def poll_asset_review(asset: Asset) -> str:
"""查单个真人资产审核状态并更新 review_status。返回最新状态。
只在状态变化时落库(保留 updated_at 作为进入 processing 的时刻);processing 超时兜底为 failed"""