优化全能创作
This commit is contained in:
@@ -105,3 +105,8 @@ MODEL_TRIVIEW_GENERATION_ENABLED=true
|
||||
# 模特上身图 V2.2:共享测试环境全量开启。仅切换提示词,不改变模型选择和积分扣费。
|
||||
MODEL_TRYON_PROMPT_V2_ENABLED=true
|
||||
MODEL_TRYON_PROMPT_V2_CANARY_TEAM_IDS=
|
||||
|
||||
# 全能创作 Agent:本机专用队列,避免远程 K8s worker 偷走未注册任务
|
||||
CREATION_AGENT_TASK_QUEUE=airshelf.local
|
||||
# 可选:API 线程内联跑 turn(不入 Celery)。队列方案足够时保持 false
|
||||
CREATION_AGENT_INLINE=true
|
||||
|
||||
@@ -15,6 +15,12 @@ DB_BIND_ADDRESS=
|
||||
REDIS_CACHE_URL=redis://127.0.0.1:6379/0
|
||||
CELERY_BROKER_URL=redis://127.0.0.1:6379/1
|
||||
CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/2
|
||||
|
||||
# 全能创作 Agent turn 队列。生产保持 celery(K8s 已部署该任务)。
|
||||
# 本机若与远程 worker 共用 broker,设为 airshelf.local,并让本机 worker -Q 含该队列。
|
||||
CREATION_AGENT_TASK_QUEUE=celery
|
||||
# true / AIRSHELF_CREATION_AGENT_INLINE=1:API 后台线程直接跑,不入 Celery(仅本机联调)
|
||||
CREATION_AGENT_INLINE=false
|
||||
REDIS_LOCK_URL=redis://127.0.0.1:6379/3
|
||||
|
||||
TOS_ENDPOINT=https://tos-s3-cn-shanghai.volces.com
|
||||
|
||||
@@ -21,7 +21,7 @@ Start workers in separate terminals:
|
||||
```bash
|
||||
cd /Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/core/backend
|
||||
source .venv/bin/activate
|
||||
celery -A airshelf worker -l info -P threads -c 4 -Q celery,airshelf.quick # 建议带 airshelf.quick;没带时编排会回退到 celery,避免一直停在「等待开始」
|
||||
celery -A airshelf worker -l info -P threads -c 4 -Q celery,airshelf.quick,airshelf.local # 建议带 airshelf.quick;本机共用远程 broker 时务必带 airshelf.local(见 CREATION_AGENT_TASK_QUEUE),否则整理方案会被远程 worker 偷走
|
||||
```
|
||||
|
||||
`ffmpeg` must be available on `PATH` for Stage5 export jobs.
|
||||
|
||||
@@ -213,6 +213,20 @@ CELERY_TASK_ROUTES = {
|
||||
"apps.projects.tasks.run_quick_script_task": {"queue": "airshelf.quick"},
|
||||
}
|
||||
|
||||
# 全能创作 Agent turn 队列。默认 celery(线上 K8s 部署了该任务后走默认队列)。
|
||||
# 本机若与远程 worker 共用 broker,远程尚未注册 run_creation_agent_turn_task 时会偷走任务 → NotRegistered / 永久 planning。
|
||||
# 本地 .env 设 CREATION_AGENT_TASK_QUEUE=airshelf.local,并让本机 worker 听该队列(-Q ...,airshelf.local)。
|
||||
CREATION_AGENT_TASK_QUEUE = (env("CREATION_AGENT_TASK_QUEUE", "celery") or "celery").strip() or "celery"
|
||||
# 本机可靠兜底:true / AIRSHELF_CREATION_AGENT_INLINE=1 时 API 进程后台线程直接跑 turn,不入共享 Celery 队列。
|
||||
# 生产保持 false。也可仅用 airshelf.local 队列而不开 inline。
|
||||
CREATION_AGENT_INLINE = env_bool("CREATION_AGENT_INLINE", False) or env_bool(
|
||||
"AIRSHELF_CREATION_AGENT_INLINE", False
|
||||
)
|
||||
if CREATION_AGENT_TASK_QUEUE and CREATION_AGENT_TASK_QUEUE != "celery":
|
||||
CELERY_TASK_ROUTES["apps.ai.tasks.run_creation_agent_turn_task"] = {
|
||||
"queue": CREATION_AGENT_TASK_QUEUE,
|
||||
}
|
||||
|
||||
REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3")
|
||||
|
||||
TOS = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(1–3 条核心支撑),"
|
||||
"不要只写 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 = []
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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 "",
|
||||
)
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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。"""
|
||||
|
||||
+24
-44
@@ -602,10 +602,11 @@ export const api = {
|
||||
);
|
||||
},
|
||||
/**
|
||||
* 发一条消息 → SSE 流。事件:tool / reasoning / delta / message / task / credits / done / error。
|
||||
* 和 agentScriptStream 同一套 fetch + ReadableStream(EventSource 只支持 GET,这里要 POST 带 body)。
|
||||
* 发一条消息 → 异步整理方案。成功 **202** `{conversation_id, agent_status}`,
|
||||
* 闸门短路径可能 **200** 带 `messages`;冲突 **409**(团队已有对话在整理方案)。
|
||||
* 前端靠 getCreation / messages?after_seq= 轮询进度,离开页面也不中断 Celery。
|
||||
*/
|
||||
async creationSendStream(
|
||||
creationSend(
|
||||
id: string,
|
||||
payload: {
|
||||
kind?: "text" | "elicit_answer";
|
||||
@@ -615,50 +616,29 @@ export const api = {
|
||||
answers?: Record<string, string | string[]>;
|
||||
model_config_id?: string;
|
||||
params?: Record<string, string>;
|
||||
},
|
||||
onEvent: (evt: { type: string; [k: string]: unknown }) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<void> {
|
||||
const token = getToken();
|
||||
const headers = new Headers({ "Content-Type": "application/json", Accept: "text/event-stream" });
|
||||
if (token) headers.set("Authorization", `Token ${token}`);
|
||||
const response = await fetch(`${API_BASE}/api/ai/creations/${id}/send/`, {
|
||||
}
|
||||
) {
|
||||
return request<{
|
||||
conversation_id: string;
|
||||
agent_status: "idle" | "planning" | "awaiting_user";
|
||||
messages?: CreationMessage[];
|
||||
}>(`/api/ai/creations/${id}/send/`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
const text = await response.text().catch(() => "");
|
||||
let message = text || "发送失败";
|
||||
try {
|
||||
const data = JSON.parse(text) as Record<string, unknown>;
|
||||
if (typeof data.detail === "string") message = data.detail;
|
||||
} catch {
|
||||
/* 非 JSON 错误体,用原文 */
|
||||
}
|
||||
throw new ApiError(response.status, message);
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let sep: number;
|
||||
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
||||
const frame = buffer.slice(0, sep);
|
||||
buffer = buffer.slice(sep + 2);
|
||||
const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
|
||||
if (!dataLine) continue;
|
||||
try {
|
||||
onEvent(JSON.parse(dataLine.slice(5).trim()));
|
||||
} catch {
|
||||
/* 跳过解析失败的帧 */
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 终止整理方案(planning)。成功 **200** `{conversation_id, agent_status: idle}`;
|
||||
* 非 planning **409**。服务端放锁 + Redis 取消标,离开页面再进也生效。
|
||||
*/
|
||||
cancelCreationAgent(id: string) {
|
||||
return request<{
|
||||
conversation_id: string;
|
||||
agent_status: "idle" | "planning" | "awaiting_user";
|
||||
}>(`/api/ai/creations/${id}/cancel/`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
},
|
||||
adoptScript(projectId: string, script_version_id: string) {
|
||||
return request<ScriptVersion>(`/api/projects/${projectId}/adopt-script/`, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ChevronDown, SlidersHorizontal, Sparkles } from "lucide-react";
|
||||
import { CustomSelect } from "./custom-select";
|
||||
import { modelDurations, modelResolutions } from "./free-create/constants";
|
||||
@@ -102,8 +103,10 @@ export function OmniParamBar({
|
||||
onDuration: (value: string) => void;
|
||||
}) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [menuPos, setMenuPos] = useState<{ top?: number; bottom?: number; left: number; width: number } | null>(null);
|
||||
const [customOn, setCustomOn] = useState(isVideo ? duration !== "智能时长" : true);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const capability = isVideo ? "video" as const : "image" as const;
|
||||
const selected = useMemo(
|
||||
@@ -123,13 +126,45 @@ export function OmniParamBar({
|
||||
setCustomOn(isVideo ? duration !== "智能时长" : true);
|
||||
}, [isVideo, duration]);
|
||||
|
||||
function placeMenu() {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const gutter = 8;
|
||||
const width = Math.min(330, window.innerWidth - gutter * 2);
|
||||
const spaceBelow = window.innerHeight - rect.bottom - gutter;
|
||||
const spaceAbove = rect.top - gutter;
|
||||
const up = spaceBelow < 280 && spaceAbove > spaceBelow;
|
||||
let left = rect.right - width;
|
||||
left = Math.min(Math.max(gutter, left), window.innerWidth - width - gutter);
|
||||
if (up) setMenuPos({ bottom: window.innerHeight - rect.top + 8, left, width });
|
||||
else setMenuPos({ top: rect.bottom + 8, left, width });
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!menuOpen) {
|
||||
setMenuPos(null);
|
||||
return;
|
||||
}
|
||||
placeMenu();
|
||||
}, [menuOpen, customOn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return;
|
||||
const onDown = (event: MouseEvent) => {
|
||||
if (!wrapRef.current?.contains(event.target as Node)) setMenuOpen(false);
|
||||
const target = event.target as Node;
|
||||
if (wrapRef.current?.contains(target) || menuRef.current?.contains(target)) return;
|
||||
setMenuOpen(false);
|
||||
};
|
||||
const onReposition = () => placeMenu();
|
||||
document.addEventListener("mousedown", onDown);
|
||||
return () => document.removeEventListener("mousedown", onDown);
|
||||
window.addEventListener("resize", onReposition);
|
||||
window.addEventListener("scroll", onReposition, true);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
window.removeEventListener("resize", onReposition);
|
||||
window.removeEventListener("scroll", onReposition, true);
|
||||
};
|
||||
}, [menuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -191,49 +226,66 @@ export function OmniParamBar({
|
||||
<span>{duration}</span>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<div className="omni-duration-menu" hidden={!menuOpen}>
|
||||
<span className="omni-duration-title">{isVideo ? "时长" : "生成张数"}</span>
|
||||
{isVideo ? (
|
||||
<div className="omni-duration-modes">
|
||||
<button
|
||||
type="button"
|
||||
className={!customOn ? "active" : ""}
|
||||
onClick={() => {
|
||||
setCustomOn(false);
|
||||
onDuration("智能时长");
|
||||
setMenuOpen(false);
|
||||
{menuOpen && menuPos
|
||||
? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="omni-duration-menu is-portal"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: menuPos.top,
|
||||
bottom: menuPos.bottom,
|
||||
left: menuPos.left,
|
||||
width: menuPos.width,
|
||||
right: "auto",
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
<Sparkles />
|
||||
<span>智能时长</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={customOn ? "active" : ""}
|
||||
onClick={() => setCustomOn(true)}
|
||||
>
|
||||
<SlidersHorizontal />
|
||||
<span>自定义时长</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="omni-duration-values" hidden={isVideo && !customOn}>
|
||||
{(isVideo ? durations.filter((value) => value !== "智能时长") : durations).map((value) => (
|
||||
<button
|
||||
type="button"
|
||||
key={value}
|
||||
className={duration === value ? "active" : ""}
|
||||
onClick={() => {
|
||||
onDuration(value);
|
||||
setCustomOn(true);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="omni-duration-title">{isVideo ? "时长" : "生成张数"}</span>
|
||||
{isVideo ? (
|
||||
<div className="omni-duration-modes">
|
||||
<button
|
||||
type="button"
|
||||
className={!customOn ? "active" : ""}
|
||||
onClick={() => {
|
||||
setCustomOn(false);
|
||||
onDuration("智能时长");
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<Sparkles />
|
||||
<span>智能时长</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={customOn ? "active" : ""}
|
||||
onClick={() => setCustomOn(true)}
|
||||
>
|
||||
<SlidersHorizontal />
|
||||
<span>自定义时长</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="omni-duration-values" hidden={isVideo && !customOn}>
|
||||
{(isVideo ? durations.filter((value) => value !== "智能时长") : durations).map((value) => (
|
||||
<button
|
||||
type="button"
|
||||
key={value}
|
||||
className={duration === value ? "active" : ""}
|
||||
onClick={() => {
|
||||
onDuration(value);
|
||||
setCustomOn(true);
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -565,7 +565,8 @@
|
||||
}
|
||||
|
||||
.omni-duration-control:has(.omni-duration-menu:not([hidden])) {
|
||||
z-index: 190;
|
||||
/* 最外层:盖过会话确认卡、sticky 输入框等 */
|
||||
z-index: 400;
|
||||
}
|
||||
|
||||
.omni-duration-trigger {
|
||||
@@ -612,7 +613,7 @@
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 190;
|
||||
z-index: 400;
|
||||
width: 330px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
@@ -625,6 +626,11 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.omni-duration-menu.is-up {
|
||||
top: auto;
|
||||
bottom: calc(100% + 8px);
|
||||
}
|
||||
|
||||
.omni-duration-title {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
@@ -1423,6 +1429,21 @@
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.omni-history-status.generating {
|
||||
color: #1a6bb5;
|
||||
}
|
||||
|
||||
.omni-history-status.generating::before {
|
||||
background: #2f8fef;
|
||||
box-shadow: 0 0 0 3px rgba(47, 143, 239, 0.22);
|
||||
animation: omni-history-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes omni-history-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.55; transform: scale(0.85); }
|
||||
}
|
||||
|
||||
/* CustomSelect 对齐稿里的 custom-select-trigger */
|
||||
.omni-parameter .rs-select {
|
||||
width: 100%;
|
||||
|
||||
@@ -104,7 +104,8 @@
|
||||
|
||||
.omni-session-feed {
|
||||
flex: 1 1 auto;
|
||||
padding: 30px 0 18px;
|
||||
/* 底部留给 sticky composer(+确认卡高度余量),最后一条消息/确认卡滚到输入框上方,不被挡住 */
|
||||
padding: 30px 0 168px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@@ -120,6 +121,8 @@
|
||||
这里改用 .is-user 避开 —— 本文件相对设计稿唯一的类名改动。 */
|
||||
.omni-chat-row.is-user {
|
||||
justify-content: flex-end;
|
||||
/* 用户气泡常从乐观 id 换成服务端 id;入场动画会让它闪一下,关掉 */
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.omni-chat-avatar {
|
||||
@@ -328,8 +331,18 @@
|
||||
}
|
||||
|
||||
.omni-plan-points i,
|
||||
.omni-plan-summary i {
|
||||
.omni-plan-points .omni-plan-point-body,
|
||||
.omni-plan-summary i,
|
||||
.omni-plan-summary .omni-plan-point-body {
|
||||
display: block;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
color: #525965;
|
||||
}
|
||||
|
||||
.omni-strategy-grid .omni-strategy-value {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.omni-plan-timeline {
|
||||
@@ -921,6 +934,7 @@
|
||||
color: #fff;
|
||||
background: var(--klein);
|
||||
cursor: pointer;
|
||||
transition: background .15s ease, color .15s ease, box-shadow .15s ease;
|
||||
}
|
||||
|
||||
.omni-session-send svg {
|
||||
@@ -928,6 +942,22 @@
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 整理方案中:发送钮变成终止(方块),中性深色贴合 omni 工具条 */
|
||||
.omni-session-send.is-stop {
|
||||
background: #2a2f38;
|
||||
color: #fff;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .08);
|
||||
}
|
||||
|
||||
.omni-session-send.is-stop:hover:not(:disabled) {
|
||||
background: #3a404a;
|
||||
}
|
||||
|
||||
.omni-session-send.is-stop svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.omni-process-card {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -1286,6 +1316,9 @@
|
||||
/* ── 确认闸门(.omni-confirm-card)· 设计稿里是方案卡下面那条操作行,这里独立成卡。
|
||||
积分数字挂在按钮里 —— 用户按下去之前就该知道要花多少。 */
|
||||
.omni-confirm-card {
|
||||
position: relative;
|
||||
/* 不要压过 sticky composer(z-index:3);下拉菜单自身另有更高 z-index */
|
||||
z-index: 1;
|
||||
width: min(760px, 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1296,6 +1329,8 @@
|
||||
border: 1px solid rgba(34, 42, 54, .10);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
/* 时长/参数下拉要冒出卡片,不能被裁 */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.omni-confirm-copy {
|
||||
@@ -1317,15 +1352,43 @@
|
||||
}
|
||||
|
||||
.omni-confirm-params {
|
||||
position: relative;
|
||||
z-index: 6;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.omni-confirm-params .omni-duration-control {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
z-index: 80;
|
||||
}
|
||||
|
||||
.omni-confirm-params .omni-duration-control:has(.omni-duration-menu:not([hidden])) {
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
/* 确认卡贴着输入框:时长菜单向上展开,避免「超出」压到 composer */
|
||||
.omni-confirm-params .omni-duration-menu {
|
||||
top: auto;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 0;
|
||||
left: auto;
|
||||
width: min(330px, calc(100vw - 48px));
|
||||
max-height: min(360px, calc(100vh - 120px));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.omni-confirm-params .omni-parameter:has(.custom-select-shell.open) {
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.omni-confirm-params .omni-parameter .custom-select-menu {
|
||||
/* 贴底时也优先向上翻,避免被 sticky composer 挡住 */
|
||||
top: auto;
|
||||
bottom: calc(100% + 6px);
|
||||
}
|
||||
|
||||
.omni-confirm-hint {
|
||||
@@ -1386,10 +1449,11 @@
|
||||
.omni-mention-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.omni-mention-chip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
@@ -1400,6 +1464,34 @@
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.omni-mention-chip.has-thumb {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.omni-mention-thumb {
|
||||
display: block;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(34, 42, 54, .1);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #f3f4f7;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.omni-mention-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.omni-mention-chip i {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 6px;
|
||||
@@ -1416,7 +1508,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-mention-chip button {
|
||||
.omni-mention-chip .omni-mention-remove {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: grid;
|
||||
@@ -1429,12 +1521,23 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.omni-mention-chip button svg {
|
||||
.omni-mention-chip.has-thumb .omni-mention-remove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #fff;
|
||||
background: rgba(34, 42, 54, .72);
|
||||
box-shadow: 0 2px 6px rgba(20, 27, 38, .18);
|
||||
}
|
||||
|
||||
.omni-mention-chip .omni-mention-remove svg {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-user .omni-mention-chip {
|
||||
.omni-mention-chips.is-user .omni-mention-chip:not(.has-thumb) {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, .14);
|
||||
}
|
||||
@@ -1444,11 +1547,20 @@
|
||||
background: #edf5ff;
|
||||
}
|
||||
|
||||
.omni-chat-stack .omni-mention-chips.is-user {
|
||||
justify-content: flex-end;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-user .omni-mention-chip.has-thumb .omni-mention-thumb {
|
||||
border-color: var(--border-faint);
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-composer {
|
||||
margin: 0 2px 8px;
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-composer .omni-mention-chip {
|
||||
.omni-mention-chips.is-composer .omni-mention-chip:not(.has-thumb) {
|
||||
color: #2b3240;
|
||||
background: #edf5ff;
|
||||
border: 1px solid rgba(0, 47, 167, .12);
|
||||
@@ -1459,7 +1571,7 @@
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.omni-mention-chips.is-composer .omni-mention-chip button {
|
||||
.omni-mention-chips.is-composer .omni-mention-chip:not(.has-thumb) .omni-mention-remove {
|
||||
color: #66707d;
|
||||
}
|
||||
|
||||
@@ -1471,15 +1583,98 @@
|
||||
max-width: min(670px, 82%);
|
||||
}
|
||||
|
||||
/* 用户短消息(如「嗯」)勿被下方 time+操作条撑宽;气泡贴合文案,长文仍受 max-width 约束 */
|
||||
.omni-chat-row.is-user .omni-chat-stack {
|
||||
align-items: flex-end;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.omni-chat-stack .omni-chat-bubble {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 发送中:不要文案;用户气泡略提亮 + 柔和呼吸,发出去后恢复纯黑 */
|
||||
.omni-chat-row.is-user .omni-chat-stack .omni-chat-bubble {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.omni-user-message-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 24px;
|
||||
margin-top: 2px;
|
||||
color: var(--black-alpha-48);
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity .15s ease, visibility 0s linear .15s;
|
||||
}
|
||||
|
||||
.omni-chat-row.is-user:hover .omni-user-message-actions,
|
||||
.omni-chat-row.is-user:focus-within .omni-user-message-actions {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.omni-user-message-actions time {
|
||||
margin-right: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.omni-user-message-actions button {
|
||||
display: grid;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--r-md);
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.omni-user-message-actions button:hover:not(:disabled) {
|
||||
color: var(--accent-black);
|
||||
background: var(--black-alpha-4);
|
||||
}
|
||||
|
||||
.omni-user-message-actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .45;
|
||||
}
|
||||
|
||||
.omni-user-message-actions svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* 发送中:不要文案;左侧柔和呼吸高亮,气泡本身不变形 */
|
||||
.omni-chat-row.is-user .omni-chat-stack.is-pending {
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.omni-chat-row.is-user .omni-chat-stack.is-pending::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: #5b6572;
|
||||
animation: omniSendPulse 1.35s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.omni-chat-row.is-user .omni-chat-stack.is-pending .omni-chat-bubble {
|
||||
background: #3a424e;
|
||||
border-color: #3a424e;
|
||||
animation: omniSendPulse 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.omni-send-spinner {
|
||||
@@ -1497,29 +1692,6 @@
|
||||
color: #5c6570;
|
||||
}
|
||||
|
||||
.omni-typing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.omni-typing i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--klein);
|
||||
opacity: .35;
|
||||
animation: omniTyping 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.omni-typing i:nth-child(2) { animation-delay: .15s; }
|
||||
.omni-typing i:nth-child(3) { animation-delay: .3s; }
|
||||
|
||||
@keyframes omniTyping {
|
||||
0%, 80%, 100% { opacity: .28; transform: translateY(0); }
|
||||
40% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
.omni-chat-bubble.is-reasoning {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1549,14 +1721,37 @@
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.omni-typing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.omni-typing i {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--klein);
|
||||
opacity: .35;
|
||||
animation: omniTyping 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.omni-typing i:nth-child(2) { animation-delay: .15s; }
|
||||
.omni-typing i:nth-child(3) { animation-delay: .3s; }
|
||||
|
||||
@keyframes omniTyping {
|
||||
0%, 80%, 100% { opacity: .28; transform: translateY(0); }
|
||||
40% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
@keyframes omniSendPulse {
|
||||
0%, 100% {
|
||||
background: #3a424e;
|
||||
box-shadow: 0 0 0 0 rgba(58, 66, 78, 0);
|
||||
background: #5b6572;
|
||||
opacity: .45;
|
||||
}
|
||||
50% {
|
||||
background: #4a5360;
|
||||
box-shadow: 0 0 0 4px rgba(58, 66, 78, .12);
|
||||
background: #8b95a3;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1602,7 +1797,7 @@
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 81;
|
||||
width: min(420px, calc(100vw - 24px));
|
||||
width: min(560px, calc(100vw - 24px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
@@ -1713,6 +1908,105 @@
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.omni-prompt-block.is-shot {
|
||||
border-color: rgba(0, 47, 167, .10);
|
||||
background: linear-gradient(180deg, #fbfcff 0%, #fff 48%);
|
||||
}
|
||||
|
||||
.omni-prompt-meta,
|
||||
.omni-prompt-shot-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.omni-prompt-meta div,
|
||||
.omni-prompt-shot-fields div {
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.omni-prompt-shot-fields {
|
||||
grid-template-columns: 1fr;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.omni-prompt-meta dt,
|
||||
.omni-prompt-shot-fields dt {
|
||||
margin: 0;
|
||||
color: #858b94;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.omni-prompt-meta dd,
|
||||
.omni-prompt-shot-fields dd {
|
||||
margin: 4px 0 0;
|
||||
color: #272d37;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.omni-prompt-block.is-table {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.omni-prompt-table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(34, 42, 54, .08);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.omni-prompt-table {
|
||||
width: 100%;
|
||||
min-width: 420px;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.omni-prompt-table th,
|
||||
.omni-prompt-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid rgba(34, 42, 54, .07);
|
||||
color: #2b3240;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.omni-prompt-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
color: #5c6570;
|
||||
background: #f6f8fb;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.omni-prompt-table th:first-child,
|
||||
.omni-prompt-table td:first-child {
|
||||
width: 72px;
|
||||
color: var(--klein);
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.omni-prompt-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.omni-prompt-table tbody tr:hover td {
|
||||
background: #fafbff;
|
||||
}
|
||||
|
||||
|
||||
.omni-gate-bubble {
|
||||
display: flex;
|
||||
@@ -1754,3 +2048,16 @@
|
||||
font-size: 12px;
|
||||
color: #8a93a0;
|
||||
}
|
||||
|
||||
|
||||
.omni-session-upload-wrap.is-uploading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.omni-upload-status {
|
||||
font-size: 12px;
|
||||
color: #5c6570;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { api, ApiError } from "../api";
|
||||
import { findCatalogModel } from "../components/omni-param-bar";
|
||||
import { modelResolutions } from "../components/free-create/constants";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
@@ -424,7 +424,11 @@ export function OmniCreatePage({
|
||||
navigate("omniSession", { conversationId: conversation.id, firstMessage: text, firstRefs: pendingRefs, firstUploads: sessionUploads });
|
||||
})
|
||||
.catch((error) => {
|
||||
onNotify?.("error", (error as Error).message);
|
||||
const status = (error as { status?: number }).status;
|
||||
onNotify?.(
|
||||
status === 409 ? "info" : "error",
|
||||
(error as Error).message || "创建失败",
|
||||
);
|
||||
setStarting(false);
|
||||
});
|
||||
}}
|
||||
@@ -615,7 +619,7 @@ export function OmniHistoryPage({
|
||||
<article
|
||||
className="omni-history-item"
|
||||
key={item.id}
|
||||
data-status={item.status}
|
||||
data-status={item.agent_status === "planning" ? "generating" : item.status}
|
||||
onClick={() => navigate("omniSession", { conversationId: item.id })}
|
||||
>
|
||||
{item.cover_url ? (
|
||||
@@ -627,9 +631,19 @@ export function OmniHistoryPage({
|
||||
)}
|
||||
<div>
|
||||
<span
|
||||
className={`omni-history-status${item.status === "completed" ? " completed" : ""}`}
|
||||
className={`omni-history-status${
|
||||
item.agent_status === "planning"
|
||||
? " generating"
|
||||
: item.status === "completed"
|
||||
? " completed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{item.status === "completed" ? "已完成" : "进行中"}
|
||||
{item.agent_status === "planning"
|
||||
? "生成中"
|
||||
: item.status === "completed"
|
||||
? "已完成"
|
||||
: "进行中"}
|
||||
</span>
|
||||
<h2>{item.title}</h2>
|
||||
<p>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -710,6 +710,8 @@ export type FreeVideoUploadResult = {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
thumb_url: string;
|
||||
review_status?: "active" | "failed" | "processing";
|
||||
in_library?: boolean;
|
||||
};
|
||||
|
||||
// 自由创作·人物素材库(火山 Assets API 引用登记)
|
||||
@@ -920,8 +922,12 @@ export type CreationMessage = {
|
||||
task: string | null;
|
||||
seq: number;
|
||||
created_at: string;
|
||||
/** 前端乐观发送用的稳定 key,避免本地气泡换成服务端 id 时整条卸载重闪 */
|
||||
clientKey?: string;
|
||||
};
|
||||
|
||||
export type CreationAgentStatus = "idle" | "planning" | "awaiting_user";
|
||||
|
||||
export type CreationConversation = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -929,6 +935,8 @@ export type CreationConversation = {
|
||||
preset: string;
|
||||
params: Record<string, string>;
|
||||
status: "running" | "completed" | "failed";
|
||||
agent_status: CreationAgentStatus;
|
||||
agent_started_at?: string | null;
|
||||
message_count: number;
|
||||
cover_url: string;
|
||||
last_active_at: string;
|
||||
|
||||
Reference in New Issue
Block a user