From 02e5dbfb60d42f39ad80b7fa0bfc49ab327fb0f1 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Fri, 11 Sep 2026 19:11:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=85=A8=E8=83=BD=E5=88=9B?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/.env | 5 + core/backend/.env.example | 6 + core/backend/README.md | 2 +- core/backend/airshelf/settings/base.py | 14 + core/backend/apps/ai/creation.py | 186 +++ core/backend/apps/ai/creation_agent.py | 391 +++++- core/backend/apps/ai/free_video.py | 19 +- core/backend/apps/ai/mentions.py | 6 +- .../0037_creationconversation_agent_status.py | 35 + core/backend/apps/ai/models.py | 11 + core/backend/apps/ai/serializers.py | 4 +- core/backend/apps/ai/tasks.py | 32 + core/backend/apps/ai/test_creation_agent.py | 48 +- .../apps/ai/test_creation_conversation.py | 47 +- .../apps/ai/test_free_video_asset_ref.py | 15 +- core/backend/apps/ai/views.py | 230 +++- core/backend/apps/assets/review.py | 25 + core/frontend/src/api.ts | 68 +- .../src/components/omni-param-bar.tsx | 140 +- core/frontend/src/omni-create-page.css | 25 +- core/frontend/src/omni-session-page.css | 385 +++++- core/frontend/src/routes/omni-create.tsx | 24 +- core/frontend/src/routes/omni-session.tsx | 1124 ++++++++++++++--- core/frontend/src/types.ts | 8 + 全能创作-契约-2026-09-02.md | 10 +- 25 files changed, 2398 insertions(+), 462 deletions(-) create mode 100644 core/backend/apps/ai/migrations/0037_creationconversation_agent_status.py diff --git a/core/backend/.env b/core/backend/.env index 2068f25..68b9fdf 100644 --- a/core/backend/.env +++ b/core/backend/.env @@ -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 diff --git a/core/backend/.env.example b/core/backend/.env.example index 680c2b7..cd243be 100644 --- a/core/backend/.env.example +++ b/core/backend/.env.example @@ -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 diff --git a/core/backend/README.md b/core/backend/README.md index d99606f..3d61888 100644 --- a/core/backend/README.md +++ b/core/backend/README.md @@ -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. diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index 6f6b770..b051d68 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -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 = { diff --git a/core/backend/apps/ai/creation.py b/core/backend/apps/ai/creation.py index 0a6ef68..1c093de 100644 --- a/core/backend/apps/ai/creation.py +++ b/core/backend/apps/ai/creation.py @@ -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 diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index b58dc5d..bb5d34e 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -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 = [] diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index c779281..815be89 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -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( diff --git a/core/backend/apps/ai/mentions.py b/core/backend/apps/ai/mentions.py index 1ce49a6..eea23b4 100644 --- a/core/backend/apps/ai/mentions.py +++ b/core/backend/apps/ai/mentions.py @@ -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 diff --git a/core/backend/apps/ai/migrations/0037_creationconversation_agent_status.py b/core/backend/apps/ai/migrations/0037_creationconversation_agent_status.py new file mode 100644 index 0000000..dc672b2 --- /dev/null +++ b/core/backend/apps/ai/migrations/0037_creationconversation_agent_status.py @@ -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"), + ), + ] diff --git a/core/backend/apps/ai/models.py b/core/backend/apps/ai/models.py index f06f779..19befd4 100644 --- a/core/backend/apps/ai/models.py +++ b/core/backend/apps/ai/models.py @@ -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: diff --git a/core/backend/apps/ai/serializers.py b/core/backend/apps/ai/serializers.py index a32d32d..693279f 100644 --- a/core/backend/apps/ai/serializers.py +++ b/core/backend/apps/ai/serializers.py @@ -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", ] diff --git a/core/backend/apps/ai/tasks.py b/core/backend/apps/ai/tasks.py index cf7f3ef..2ce911b 100644 --- a/core/backend/apps/ai/tasks.py +++ b/core/backend/apps/ai/tasks.py @@ -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 "", + ) diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index 8835dc0..2e0bada 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -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() diff --git a/core/backend/apps/ai/test_creation_conversation.py b/core/backend/apps/ai/test_creation_conversation.py index f6b3a1f..2f53dc4 100644 --- a/core/backend/apps/ai/test_creation_conversation.py +++ b/core/backend/apps/ai/test_creation_conversation.py @@ -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)) diff --git a/core/backend/apps/ai/test_free_video_asset_ref.py b/core/backend/apps/ai/test_free_video_asset_ref.py index fa897dc..45b2b4a 100644 --- a/core/backend/apps/ai/test_free_video_asset_ref.py +++ b/core/backend/apps/ai/test_free_video_asset_ref.py @@ -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") diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 967107a..f3ace6d 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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): diff --git a/core/backend/apps/assets/review.py b/core/backend/apps/assets/review.py index e862509..4e503c6 100644 --- a/core/backend/apps/assets/review.py +++ b/core/backend/apps/assets/review.py @@ -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。""" diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index df12bf2..6c9648b 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -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; model_config_id?: string; params?: Record; - }, - onEvent: (evt: { type: string; [k: string]: unknown }) => void, - signal?: AbortSignal - ): Promise { - 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; - 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(`/api/projects/${projectId}/adopt-script/`, { diff --git a/core/frontend/src/components/omni-param-bar.tsx b/core/frontend/src/components/omni-param-bar.tsx index b664720..c78259b 100644 --- a/core/frontend/src/components/omni-param-bar.tsx +++ b/core/frontend/src/components/omni-param-bar.tsx @@ -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(null); + const menuRef = useRef(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({ {duration} - + {isVideo ? "时长" : "生成张数"} + {isVideo ? ( +
+ + +
+ ) : null} + + , + document.body, + ) + : null} ); diff --git a/core/frontend/src/omni-create-page.css b/core/frontend/src/omni-create-page.css index 3897e4a..bebac32 100644 --- a/core/frontend/src/omni-create-page.css +++ b/core/frontend/src/omni-create-page.css @@ -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%; diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 461a4e4..0845e1e 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -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; +} diff --git a/core/frontend/src/routes/omni-create.tsx b/core/frontend/src/routes/omni-create.tsx index 0df6bb6..dc803bf 100644 --- a/core/frontend/src/routes/omni-create.tsx +++ b/core/frontend/src/routes/omni-create.tsx @@ -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({
navigate("omniSession", { conversationId: item.id })} > {item.cover_url ? ( @@ -627,9 +631,19 @@ export function OmniHistoryPage({ )}
- {item.status === "completed" ? "已完成" : "进行中"} + {item.agent_status === "planning" + ? "生成中" + : item.status === "completed" + ? "已完成" + : "进行中"}

{item.title}

diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index b986c09..06592ca 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -4,13 +4,16 @@ import { ArrowLeft, ArrowUp, Box, + Copy, Download, FileText, FolderOpen, Image, Play, Plus, + Pencil, Sparkles, + Square, Upload, UserRound, Users, @@ -29,38 +32,164 @@ import type { } from "../types"; import type { NavigateFn } from "./route-config"; -function promptSections(body: string): Array<{ heading: string; text: string }> { - // 认 markdown 标题、【块】、以及「0-1.2s Hook / 0-1.2秒 镜头1」这种出片常用分段 - const headingRe = /^(?:#{1,3}\s+.+|【.+】|\d+(?:\.\d+)?\s*[-–~//]\s*\d+(?:\.\d+)?\s*(?:s|秒)\b.*|(?:Hook|Body|CTA|过桥|正文|总览|概述|整体|镜头)\b.*)$/i; - const sections: Array<{ heading: string; text: string }> = []; - let heading = ""; - let text = ""; - const flush = () => { - const next = text.trim(); - if (heading || next) sections.push({ heading, text: next }); - heading = ""; - text = ""; - }; - const exploded = body.replace(/\r\n/g, "\n").replace( +type PromptBlock = + | { type: "meta"; entries: Array<{ label: string; value: string }> } + | { type: "table"; headers: string[]; rows: string[][] } + | { type: "shot"; heading: string; fields: Array<{ label: string; value: string }>; text: string } + | { type: "text"; heading: string; text: string }; + +function parseMarkdownTable(lines: string[]): { headers: string[]; rows: string[][] } | null { + if (lines.length < 2) return null; + const splitRow = (line: string) => + line + .trim() + .replace(/^\|/, "") + .replace(/\|$/, "") + .split("|") + .map((cell) => cell.trim()); + const headers = splitRow(lines[0]); + if (headers.length < 2) return null; + const divider = lines[1].trim(); + if (!/^\|?[\s:\-|]+ \|?/.test(divider) && !/^[\s:\-|]+$/.test(divider.replace(/\|/g, ""))) { + // 宽松:第二行主要是 --- 分隔 + if (!divider.includes("---") && !divider.includes(":-")) return null; + } + const rows = lines.slice(2).map(splitRow).filter((row) => row.some(Boolean)); + return { headers, rows }; +} + +function extractShotFields(text: string): { fields: Array<{ label: string; value: string }>; rest: string } { + const labels = ["景别", "机位", "运镜", "动作", "信息变化", "人声", "画面", "口播文案", "口播", "时长", "画面描述"]; + const fields: Array<{ label: string; value: string }> = []; + let rest = text; + for (const label of labels) { + const re = new RegExp(`(?:^|\\n)\\s*${label}\\s*[::]\\s*([^\\n]+)`, "i"); + const match = rest.match(re); + if (!match) continue; + fields.push({ label, value: match[1].trim() }); + rest = rest.replace(match[0], "\n"); + } + return { fields, rest: rest.replace(/\n{3,}/g, "\n\n").trim() }; +} + +function promptBlocks(body: string): PromptBlock[] { + const headingRe = + /^(?:#{1,3}\s+.+|【.+】|\d+(?:\.\d+)?\s*[-–~//]\s*\d+(?:\.\d+)?\s*(?:s|秒)\b.*|(?:Hook|Body|CTA|过桥|正文|总览|概述|整体|镜头)\b.*)$/i; + const metaRe = /^(.{1,20}?)[::]\s+(.+)$/; + const rawLines = body.replace(/\r\n/g, "\n").replace( /(? = []; + while (i < rawLines.length) { + const raw = rawLines[i].trim(); + if (!raw) { + if (meta.length) break; + i += 1; + continue; + } + if (raw.startsWith("|") || headingRe.test(raw) || raw.startsWith("#")) break; + const m = raw.match(metaRe); + if (!m || headingRe.test(raw)) break; + // 避免把「0-3秒:xxx」当 meta + if (/^\d/.test(m[1])) break; + meta.push({ label: m[1].trim(), value: m[2].trim() }); + i += 1; + } + if (meta.length) blocks.push({ type: "meta", entries: meta }); + + let heading = ""; + let textLines: string[] = []; + const flushText = () => { + const text = textLines.join("\n").trim(); + textLines = []; + if (!heading && !text) return; + const bodyLines = text ? text.split("\n") : []; + const tableIdx = bodyLines.findIndex((line) => line.trim().startsWith("|")); + if (tableIdx >= 0) { + let end = tableIdx + 1; + while (end < bodyLines.length && bodyLines[end].trim().startsWith("|")) end += 1; + const before = bodyLines.slice(0, tableIdx).join("\n").trim(); + const table = parseMarkdownTable(bodyLines.slice(tableIdx, end)); + const after = bodyLines.slice(end).join("\n").trim(); + if (before || heading) { + const shotLike = /镜头|\d+(?:\.\d+)?\s*[-–~//]\s*\d+/.test(heading); + if (shotLike && heading) { + const extracted = extractShotFields(before); + blocks.push({ type: "shot", heading, fields: extracted.fields, text: extracted.rest }); + } else { + blocks.push({ type: "text", heading, text: before }); + } + } + if (table) blocks.push({ type: "table", headers: table.headers, rows: table.rows }); + if (after) blocks.push({ type: "text", heading: "", text: after }); + heading = ""; + return; + } + const shotLike = /镜头|\d+(?:\.\d+)?\s*[-–~//]\s*\d+|(?:Hook|Body|CTA|过桥|正文)/i.test(heading); + if (shotLike && heading) { + const extracted = extractShotFields(text); + blocks.push({ type: "shot", heading, fields: extracted.fields, text: extracted.rest }); + } else { + blocks.push({ type: "text", heading, text }); + } + heading = ""; + }; + + while (i < rawLines.length) { + const line = rawLines[i]; const raw = line.trim(); + // 独立 markdown 表 + if (raw.startsWith("|")) { + flushText(); + let end = i + 1; + while (end < rawLines.length && rawLines[end].trim().startsWith("|")) end += 1; + const table = parseMarkdownTable(rawLines.slice(i, end)); + if (table) blocks.push({ type: "table", headers: table.headers, rows: table.rows }); + else blocks.push({ type: "text", heading: "", text: rawLines.slice(i, end).join("\n") }); + i = end; + continue; + } if (raw && headingRe.test(raw)) { - flush(); + flushText(); heading = raw.replace(/^#+\s*/, ""); const split = heading.match(/^(.{2,40}?)[::]\s+(.+)$/); if (split) { heading = split[1].trim(); - text = split[2]; + textLines = [split[2]]; } + i += 1; continue; } - text += (text ? "\n" : "") + line; + textLines.push(line); + i += 1; } - flush(); - return sections.length ? sections : [{ heading: "", text: body.trim() }]; + flushText(); + return blocks.length ? blocks : [{ type: "text", heading: "", text: body.trim() }]; +} + +/** @deprecated 兼容旧调用;新 UI 用 promptBlocks */ +function promptSections(body: string): Array<{ heading: string; text: string }> { + return promptBlocks(body).flatMap((block) => { + if (block.type === "meta") { + return [{ heading: "视频参数", text: block.entries.map((e) => `${e.label}:${e.value}`).join("\n") }]; + } + if (block.type === "table") { + const head = `| ${block.headers.join(" | ")} |`; + const rows = block.rows.map((r) => `| ${r.join(" | ")} |`).join("\n"); + return [{ heading: "", text: `${head}\n${rows}` }]; + } + if (block.type === "shot") { + const fieldText = block.fields.map((f) => `${f.label}:${f.value}`).join("\n"); + return [{ heading: block.heading, text: [fieldText, block.text].filter(Boolean).join("\n") }]; + } + return [{ heading: block.heading, text: block.text }]; + }); } const REF_CHIP_LABEL: Record = { @@ -89,6 +218,16 @@ function stripMentionText(text: string, refs: CreationRef[]) { return out.replace(/@\s*$/g, "").trim(); } +function messageTime(createdAt: string) { + const date = new Date(createdAt); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + function MentionChips({ refs, tone = "user", @@ -98,35 +237,87 @@ function MentionChips({ tone?: "user" | "composer"; onRemove?: (id: string) => void; }) { + const [preview, setPreview] = useState<{ src: string; name: string } | null>(null); if (!refs.length) return null; return ( -

- {refs.map((ref) => ( - - {REF_CHIP_LABEL[ref.type] || ref.type} - {shortRefName(ref.name)} - {onRemove ? ( - - ) : null} - - ))} -
+ <> +
+ {refs.map((ref) => { + const cover = (ref.cover || "").trim(); + const label = shortRefName(ref.name) || REF_CHIP_LABEL[ref.type] || ref.type; + return ( + + {cover ? ( + + ) : ( + <> + {REF_CHIP_LABEL[ref.type] || ref.type} + {label} + + )} + {onRemove ? ( + + ) : null} + + ); + })} +
+ setPreview(null)} + /> + ); } /** 生成中的消息靠轮询转成结果。视频 5–10 分钟,间隔别设太密。 */ const POLL_INTERVAL_MS = 4000; +const AGENT_POLL_INTERVAL_MS = 1500; // ────────────────────────────────────────────────────────── 卡片 +function strategyField(payload: Record, ...keys: string[]): string { + for (const key of keys) { + const value = payload[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + if (value && typeof value === "object") { + const nest = value as Record; + for (const nested of ["text", "value", "content", "desc"]) { + const inner = nest[nested]; + if (typeof inner === "string" && inner.trim()) return inner.trim(); + } + } + } + return ""; +} + function StrategyCard({ payload }: { payload: Record }) { const items: Array<[string, string]> = [ - ["这条视频给谁看", String(payload.target || "")], - ["用户为什么相信", String(payload.trust || "")], - ["希望用户相信什么", String(payload.belief || "")], + ["这条视频给谁看", strategyField(payload, "target", "audience", "who", "给谁看", "目标人群")], + ["用户为什么相信", strategyField(payload, "trust", "credibility", "为什么相信", "信任")], + ["希望用户相信什么", strategyField(payload, "belief", "希望相信", "想让他信什么", "认知")], ]; + const direction = strategyField(payload, "direction", "创作方向", "方向", "style"); return (
@@ -137,14 +328,14 @@ function StrategyCard({ payload }: { payload: Record }) { {items.map(([label, value]) => (
{label} - {value} + {value || "—"}
))}
- {payload.direction ? ( + {direction ? (
创作方向 - {String(payload.direction)} + {direction}
) : null} @@ -152,12 +343,55 @@ function StrategyCard({ payload }: { payload: Record }) { } type PlanTimelineItem = { start: number; end: number; stage: string; desc?: string }; +type PlanMatrix = { shots?: number; rows?: Array<{ point: string; hits?: number[] }> }; + +function coercePlanPoints(raw: unknown): string[] { + if (Array.isArray(raw)) { + return raw + .map((item) => { + if (typeof item === "string") return item.trim(); + if (item && typeof item === "object") { + const nest = item as Record; + for (const key of ["text", "value", "content", "desc", "point", "label"]) { + const value = nest[key]; + if (typeof value === "string" && value.trim()) return value.trim(); + } + } + return ""; + }) + .filter((text) => text.length > 1) + .slice(0, 3); + } + if (typeof raw === "string" && raw.trim()) { + return raw + .split(/[\n;;]/) + .map((part) => part.trim()) + .filter((part) => part.length > 1) + .slice(0, 3); + } + return []; +} + +function coerceVoiceChars(raw: unknown): number[] { + if (Array.isArray(raw) && raw.length >= 2) { + const lo = Number(raw[0]); + const hi = Number(raw[1]); + if (Number.isFinite(lo) && Number.isFinite(hi) && lo > 0 && hi >= lo) return [lo, hi]; + } + if (typeof raw === "number" && raw > 0) return [Math.max(1, raw - 5), raw + 5]; + return []; +} function PlanCard({ payload }: { payload: Record }) { - const points = (payload.points as string[] | undefined) || []; + const usp = strategyField(payload, "usp", "主打卖点", "卖点"); + const points = coercePlanPoints(payload.points); const timeline = (payload.timeline as PlanTimelineItem[] | undefined) || []; - const voice = (payload.voice_chars as number[] | undefined) || []; + const voice = coerceVoiceChars(payload.voice_chars); + const matrix = (payload.matrix as PlanMatrix | undefined) || undefined; + const matrixRows = Array.isArray(matrix?.rows) ? matrix!.rows! : []; + const shotCount = Math.max(4, Number(matrix?.shots) || 4); const format = (value: number) => (Number.isInteger(value) ? String(value) : value.toFixed(1)); + const pointLabels = ["核心支撑 P0", "视觉支撑 P0", "转化支撑 P0"]; return (
@@ -170,12 +404,12 @@ function PlanCard({ payload }: { payload: Record }) {
主打卖点 USP - {String(payload.usp || "")} + {usp || "—"} {points.map((point, index) => ( - 核心支撑 P{index} - {point} + {pointLabels[index] || `核心支撑 P${index}`} + {point} ))}
@@ -184,8 +418,8 @@ function PlanCard({ payload }: { payload: Record }) {
Hook 只负责留人,正文负责说服
- {timeline.map((item) => ( - + {timeline.map((item, index) => ( + {format(item.start)}–{format(item.end)} 秒 · {item.stage} @@ -195,6 +429,36 @@ function PlanCard({ payload }: { payload: Record }) {
) : null} + {matrixRows.length > 0 ? ( +
+ 卖点覆盖矩阵 + + + + + {Array.from({ length: shotCount }, (_, index) => ( + + ))} + + + + {matrixRows.map((row) => { + const hits = new Set((row.hits || []).map((n) => Number(n))); + return ( + + + {Array.from({ length: shotCount }, (_, index) => ( + + ))} + + ); + })} + +
卖点镜头 {index + 1}
{row.point} + {hits.has(index + 1) ? "✓" : ""} +
+
+ ) : null}
{voice.length === 2 ? ( @@ -208,7 +472,7 @@ function PlanCard({ payload }: { payload: Record }) { 最终输入: - Prompt.md + {String(payload.ref_count ?? 0)} 组参考素材 + Prompt.md + {String(payload.ref_count ?? 0)} 组参考素材
@@ -268,7 +532,7 @@ function ElicitCard({ void Promise.all( assetFields.map((f) => api - .searchMentions({ types: f.asset_types, limit: 8 }) + .searchMentions({ types: f.asset_types, limit: 24 }) .then((res) => [f.key, res.results] as const) .catch(() => [f.key, [] as CreationRef[]] as const) ) @@ -628,48 +892,165 @@ function isLocalUserId(id: string) { return id.startsWith("local-user-"); } +function serverSeq(message: CreationMessage) { + const seq = Number(message.seq); + if (!Number.isFinite(seq) || seq >= Number.MAX_SAFE_INTEGER) return 0; + return seq; +} + +/** 当前列表里已落库消息的最大 seq;乐观 local 用 MAX_SAFE_INTEGER,不算进去 */ +function lastKnownServerSeq(list: CreationMessage[]) { + let max = 0; + for (const message of list) { + if (isLocalUserId(message.id)) continue; + max = Math.max(max, serverSeq(message)); + } + return max; +} + +/** 乐观用户气泡:发请求前立刻上屏,poll/202 回来再换成服务端消息 */ +function makeLocalUserMessage(text: string, refs: CreationRef[], id?: string): CreationMessage { + const localId = id || `local-user-${Date.now()}`; + return { + id: localId, + role: "user", + kind: "text", + text: text || "", + payload: {}, + refs: refs || [], + task: null, + seq: Number.MAX_SAFE_INTEGER, + created_at: new Date().toISOString(), + clientKey: localId, + }; +} + +/** + * 把服务端用户消息嵌进本地列表。 + * 只替换「当前 pending 的 local 气泡」,绝不按正文去覆盖历史已发布用户消息 + * (编辑后重发相同文案时,旧气泡和新气泡必须同时存在)。 + */ function replaceLocalUser(prev: CreationMessage[], message: CreationMessage): CreationMessage[] { const existing = prev.findIndex((m) => m.id === message.id); if (existing !== -1) { const next = [...prev]; - next[existing] = message; + const prevMsg = prev[existing]; + next[existing] = { ...message, clientKey: prevMsg.clientKey || message.clientKey || message.id }; return next; } if (message.role === "user") { - const localIdx = prev.findIndex((m) => isLocalUserId(m.id)); - if (localIdx !== -1) { - const next = [...prev]; - next[localIdx] = message; - return next; - } - const dup = prev.findIndex((m) => m.role === "user" && m.text === message.text); - if (dup !== -1) { - const next = [...prev]; - next[dup] = message; - return next; + const knownSeq = lastKnownServerSeq(prev); + const isFreshServerUser = serverSeq(message) > knownSeq; + // 仅当这条服务端用户消息确实是「新一轮」时,才拿它替换 pending local + if (isFreshServerUser) { + const localIdx = prev.findIndex((m) => isLocalUserId(m.id) && sameUserBubble(m, message)); + if (localIdx !== -1) { + const next = [...prev]; + const local = prev[localIdx]; + next[localIdx] = { ...message, clientKey: local.clientKey || local.id }; + return next; + } + // 也允许按「唯一 pending local」对齐(正文被服务端微调时) + const pendingLocals = prev + .map((m, idx) => ({ m, idx })) + .filter(({ m }) => isLocalUserId(m.id)); + if (pendingLocals.length === 1) { + const { idx, m: local } = pendingLocals[0]; + const next = [...prev]; + next[idx] = { ...message, clientKey: local.clientKey || local.id }; + return next; + } } } - return [...prev, message]; + return [...prev, { ...message, clientKey: message.clientKey || message.id }]; } +/** + * 只折叠「同一轮」里相邻的 local ↔ server 重复; + * 两个都已落库的用户消息即使正文相同也不合并(允许编辑重发相同文案)。 + */ function collapseDupUser(list: CreationMessage[]): CreationMessage[] { const out: CreationMessage[] = []; for (const m of list) { const last = out[out.length - 1]; - if (last?.role === "user" && m.role === "user" && last.text === m.text) { - out[out.length - 1] = isLocalUserId(m.id) ? last : m; + const consecutiveSameText = + last?.role === "user" + && m.role === "user" + && (last.text || "") === (m.text || ""); + const localServerPair = + consecutiveSameText + && (isLocalUserId(last.id) !== isLocalUserId(m.id)); + if (localServerPair) { + if (isLocalUserId(m.id)) { + out[out.length - 1] = last; + } else { + out[out.length - 1] = { + ...m, + clientKey: last.clientKey || m.clientKey || m.id, + }; + } continue; } - out.push(m); + out.push(m.clientKey ? m : { ...m, clientKey: m.id }); } return out; } +function sameUserBubble(a: CreationMessage, b: CreationMessage) { + if (a.role !== "user" || b.role !== "user") return false; + if ((a.text || "") !== (b.text || "")) return false; + const aRefs = (a.refs || []).map((r) => `${r.type}:${r.id}`).sort().join("|"); + const bRefs = (b.refs || []).map((r) => `${r.type}:${r.id}`).sort().join("|"); + return aRefs === bRefs; +} + +function turnLooksSettled(detail: CreationConversationDetail) { + const msgs = detail.messages || []; + let userIdx = -1; + for (let i = msgs.length - 1; i >= 0; i -= 1) { + if (msgs[i].role === "user") { + userIdx = i; + break; + } + } + if (userIdx === -1) return false; + // 用户气泡之后已有助手/系统/卡片消息 → 本轮已落地,可收起 thinking + return msgs.slice(userIdx + 1).some((m) => m.role !== "user"); +} + +/** + * 合并服务端快照时保留尚未 ack 的乐观气泡。 + * 关键:只能把 local 对上「本轮新出现」的服务端用户消息(id 未见过或 seq 更大), + * 绝不能因为历史里有一条相同文案就把 pending local 吃掉。 + */ function keepUnackedLocals(prev: CreationMessage[], incoming: CreationMessage[]): CreationMessage[] { - const leftover = prev.filter( - (m) => isLocalUserId(m.id) && !incoming.some((n) => n.role === "user" && n.text === m.text) + const locals = prev.filter((m) => isLocalUserId(m.id)); + if (!locals.length) { + return incoming.map((msg) => (msg.clientKey ? msg : { ...msg, clientKey: msg.id })); + } + const knownServerIds = new Set( + prev.filter((m) => !isLocalUserId(m.id)).map((m) => m.id) ); - return collapseDupUser(leftover.length ? [...incoming, ...leftover] : incoming); + const knownSeq = lastKnownServerSeq(prev); + const used = new Set(); + const merged = incoming.map((msg) => { + const prevHit = prev.find((m) => m.id === msg.id); + const withKey = { + ...msg, + clientKey: prevHit?.clientKey || msg.clientKey || msg.id, + }; + if (msg.role !== "user") return withKey; + const isNewServerUser = + !knownServerIds.has(msg.id) && serverSeq(msg) > knownSeq; + if (!isNewServerUser) return withKey; + const local = locals.find((item) => !used.has(item.id) && sameUserBubble(item, msg)); + if (!local) return withKey; + used.add(local.id); + return { ...msg, clientKey: local.clientKey || local.id }; + }); + const leftover = locals.filter((m) => !used.has(m.id)); + // leftover 追加在末尾;不要对历史做正文去重 + return collapseDupUser(leftover.length ? [...merged, ...leftover] : merged); } function withoutLegacyGateArtifacts(list: CreationMessage[]): CreationMessage[] { @@ -738,7 +1119,11 @@ const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: type function mergeSessionUploads(results: CreationRef[], uploads: CreationRef[], query: string, type: CreationRef["type"]) { if (type !== "asset") return results; const q = query.trim().toLowerCase(); - const extras = uploads.filter((item) => !q || item.name.toLowerCase().includes(q)); + // 上传时间倒序:sessionUploads 末尾最新,反过来放最前 + const extras = uploads + .filter((item) => !q || item.name.toLowerCase().includes(q)) + .slice() + .reverse(); const seen = new Set(extras.map((item) => item.id)); return [...extras, ...results.filter((item) => !seen.has(item.id))]; } @@ -763,13 +1148,24 @@ export function OmniSessionPage({ navigate: NavigateFn; onNotify?: (type: "success" | "error" | "info", text: string) => void; }) { + // 首页带过来的第一句:进页立刻画出用户气泡,不要等 getCreation / creationSend + const bootLocalIdRef = useRef( + (firstMessage?.trim() || (firstRefs && firstRefs.length > 0)) + ? `local-user-${Date.now()}` + : null + ); const [conversation, setConversation] = useState(null); - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState(() => { + const id = bootLocalIdRef.current; + if (!id) return []; + return [makeLocalUserMessage(firstMessage?.trim() || "", firstRefs || [], id)]; + }); const [prompt, setPrompt] = useState(""); const [pendingRefs, setPendingRefs] = useState([]); const [sessionUploads, setSessionUploads] = useState(firstUploads || []); const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); + const composerRef = useRef(null); const [streaming, setStreaming] = useState(false); const [liveText, setLiveText] = useState(""); const [liveReasoning, setLiveReasoning] = useState(""); @@ -782,8 +1178,9 @@ export function OmniSessionPage({ const mentionWrapRef = useRef(null); const [uploadMenuOpen, setUploadMenuOpen] = useState(false); const [confirming, setConfirming] = useState(false); + const [stopping, setStopping] = useState(false); const [promptView, setPromptView] = useState<{ title: string; body: string } | null>(null); - const [pendingUserId, setPendingUserId] = useState(null); + const [pendingUserId, setPendingUserId] = useState(() => bootLocalIdRef.current); // App 传进来的 onNotify 是内联箭头,**每次 App 重渲染都是新身份**。 // 直接放进 useEffect / useCallback 依赖会让整条会话被反复重拉、消息数组被反复替换 // —— 页面看起来就是一直在跳。放进 ref,回调永远拿到最新的那个,身份却是稳定的。 @@ -794,11 +1191,41 @@ export function OmniSessionPage({ [] ); + const copyUserMessage = useCallback(async (text: string) => { + try { + await navigator.clipboard.writeText(text); + notify("success", "已复制"); + } catch { + notify("error", "复制失败,请手动复制"); + } + }, [notify]); + + const editUserMessage = useCallback((message: CreationMessage) => { + const refs = message.refs || []; + setPrompt(stripMentionText(message.text, refs)); + setPendingRefs(refs); + requestAnimationFrame(() => { + composerRef.current?.focus(); + composerRef.current?.setSelectionRange( + composerRef.current.value.length, + composerRef.current.value.length, + ); + }); + }, []); + const feedRef = useRef(null); - const abortRef = useRef(null); const firstSentRef = useRef(false); - const pendingUserIdRef = useRef(null); + const pendingUserIdRef = useRef(bootLocalIdRef.current); + /** 发送时已见过的最大服务端 seq;只有更新的用户消息才能 ack 当前 pending local */ + const pendingSinceSeqRef = useRef(0); + const messagesRef = useRef([]); const streamingRef = useRef(false); + /** 刚发出去后短时间忽略轮询里的陈旧 idle,避免 thinking 被冲掉 */ + const holdPlanningUntilRef = useRef(0); + /** 用户点了终止:忽略随后可能迟到的 planning 快照,直到真正 idle */ + const userCancelledRef = useRef(false); + /** 卡在 planning 过久时只提示一次,避免轮询刷屏 */ + const stalePlanningNotifiedRef = useRef(false); const isVideo = conversation?.mode === "video"; const params = useMemo(() => conversation?.params || {}, [conversation]); @@ -810,7 +1237,26 @@ export function OmniSessionPage({ .then((detail) => { if (cancelled) return; setConversation(detail); - setMessages(detail.messages); + // 保留尚未 ack 的乐观用户气泡,避免进页种子/发送中的气泡被首包冲掉 + setMessages((prev) => keepUnackedLocals(prev, detail.messages || [])); + if (bootLocalIdRef.current && pendingUserIdRef.current === bootLocalIdRef.current) { + const boot = makeLocalUserMessage( + firstMessage?.trim() || "", + firstRefs || [], + bootLocalIdRef.current + ); + const acked = (detail.messages || []).some( + (m) => m.role === "user" && sameUserBubble(m, boot) + ); + if (acked) { + pendingUserIdRef.current = null; + setPendingUserId(null); + } + } + // 刷新/重进时若仍在整理方案,直接恢复轮询 UI —— 不要 abort 杀 Celery + const planning = detail.agent_status === "planning"; + streamingRef.current = planning; + setStreaming(planning); }) .catch((error) => notify("error", (error as Error).message)); return () => { @@ -856,8 +1302,8 @@ export function OmniSessionPage({ // 流式吐字:instant + rAF 合帧,不然逐字 smooth 会把页面抖散 useEffect(() => { - if (liveText) scrollToBottom(false); - }, [liveText, scrollToBottom]); + if (liveText || liveReasoning) scrollToBottom(false); + }, [liveText, liveReasoning, scrollToBottom]); useEffect(() => () => { if (scrollFrameRef.current) cancelAnimationFrame(scrollFrameRef.current); @@ -871,6 +1317,15 @@ export function OmniSessionPage({ const refs = firstRefs || []; if ((!text && refs.length === 0) || conversation.messages.length > 0) { firstSentRef.current = true; + // 库里已有消息时丢掉进页种子,避免和历史重影 + if (bootLocalIdRef.current) { + const bootId = bootLocalIdRef.current; + setMessages((prev) => prev.filter((m) => m.id !== bootId)); + if (pendingUserIdRef.current === bootId) { + pendingUserIdRef.current = null; + setPendingUserId(null); + } + } return; } firstSentRef.current = true; @@ -878,7 +1333,7 @@ export function OmniSessionPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [conversation, firstMessage]); - useEffect(() => () => abortRef.current?.abort(), []); + // 离开页面不再 abort:整理方案在 Celery,断连不该停任务。 useEffect(() => { const fromHistory = messages.flatMap((message) => message.refs || []).filter((ref) => ref.type === "asset"); @@ -895,6 +1350,7 @@ export function OmniSessionPage({ [messages] ); const visibleMessages = useMemo(() => withoutLegacyGateArtifacts(messages), [messages]); + messagesRef.current = messages; // 出片在 worker 里跑。刷新后 GENERATING 还在库里,进来立刻拉一次再轮询, // 不要等流式对话结束,也不要空等一个间隔才看见进度。 @@ -933,75 +1389,248 @@ export function OmniSessionPage({ }; }, [hasGenerating, conversationId]); - const applyEvent = useCallback((event: { type: string; [k: string]: unknown }) => { - if (event.type === "message") { - const message = event.message as CreationMessage; - setLiveText(""); - setLiveReasoning(""); - setMessages((prev) => collapseDupUser(replaceLocalUser(prev, message))); - if (message.role === "user" && pendingUserIdRef.current) { + const mergeCreationDetail = useCallback((detail: CreationConversationDetail, localId?: string | null, payloadText?: string) => { + setConversation((prev) => { + if ( + prev + && prev.id === detail.id + && prev.agent_status === detail.agent_status + && prev.updated_at === detail.updated_at + && prev.params === detail.params + ) { + return prev; + } + return detail; + }); + setMessages((prev) => { + const before = new Map(prev.map((m) => [m.id, m])); + let changed = prev.length !== detail.messages.length; + const merged = detail.messages.map((next) => { + const oldMsg = before.get(next.id); + if ( + oldMsg + && oldMsg.kind === next.kind + && oldMsg.text === next.text + && JSON.stringify(oldMsg.payload) === JSON.stringify(next.payload) + ) { + return oldMsg; + } + changed = true; + return next; + }); + return keepUnackedLocals(prev, changed ? merged : prev); + }); + if (localId && pendingUserIdRef.current === localId) { + // 必须是「发送之后」新出现的用户消息,不能靠正文撞上历史气泡 + const acked = detail.messages.some( + (m) => + m.role === "user" + && !isLocalUserId(m.id) + && serverSeq(m) > pendingSinceSeqRef.current + && (payloadText == null || payloadText === "" || m.text === payloadText) + ); + if (acked) { pendingUserIdRef.current = null; setPendingUserId(null); } - return; } - if (event.type === "reasoning") { - setLiveReasoning((prev) => prev + String(event.text || "")); - return; - } - if (event.type === "delta") { - setLiveText((prev) => prev + String(event.text || "")); - return; - } - if (event.type === "tool") { - setActiveTool(event.status === "running" ? String(event.label || "") : ""); - return; - } - if (event.type === "error") { + const remotePlanning = detail.agent_status === "planning"; + const remoteAwaiting = detail.agent_status === "awaiting_user"; + const holding = Date.now() < holdPlanningUntilRef.current; + const clearThinking = () => { + holdPlanningUntilRef.current = 0; + streamingRef.current = false; + setStreaming(false); setLiveText(""); setLiveReasoning(""); + setActiveTool(""); + }; + + if (remoteAwaiting) { + // 轮次结束:立刻收起 thinking,不再被 hold 拖住 + stalePlanningNotifiedRef.current = false; + clearThinking(); + return; } - }, []); + + if (remotePlanning) { + if (userCancelledRef.current) { + // 用户已终止:等服务端落到 idle,不把 thinking 又拉起来 + return; + } + // 本轮已落 error:不要继续挂「正在整理方案」 + const msgs = detail.messages || []; + let lastUserIdx = -1; + for (let i = msgs.length - 1; i >= 0; i -= 1) { + if (msgs[i].role === "user") { + lastUserIdx = i; + break; + } + } + if (lastUserIdx !== -1 && msgs.slice(lastUserIdx + 1).some((m) => m.kind === "error")) { + stalePlanningNotifiedRef.current = false; + clearThinking(); + setConversation((prev) => (prev ? { ...prev, agent_status: "idle" } : prev)); + return; + } + // 服务端卡死过久(worker 挂掉 / 任务未消费):尝试 cancel 并收起,避免假 idle 导致下一发 409 + const startedMs = detail.agent_started_at ? Date.parse(detail.agent_started_at) : 0; + const staleMs = 15 * 60 * 1000; + if (startedMs && Date.now() - startedMs > staleMs) { + if (!stalePlanningNotifiedRef.current) { + stalePlanningNotifiedRef.current = true; + notify("error", "整理方案超时,已停止,请重试"); + void api.cancelCreationAgent(conversationId).catch(() => { + /* 已 idle / 竞态忽略 */ + }); + } + clearThinking(); + setConversation((prev) => (prev ? { ...prev, agent_status: "idle" } : prev)); + return; + } + // 服务端确认 planning 后,再续一小段 hold,挡住中间夹杂的旧 idle 快照 + holdPlanningUntilRef.current = Math.max(holdPlanningUntilRef.current, Date.now() + 2500); + streamingRef.current = true; + setStreaming(true); + return; + } + + // idle / 其它:用户终止完成 + stalePlanningNotifiedRef.current = false; + if (userCancelledRef.current) { + userCancelledRef.current = false; + } + + // idle:hold 窗口内且本轮还没落助手消息时,忽略陈旧快照;已落消息或窗口过期则收起 + if (holding && streamingRef.current && !turnLooksSettled(detail)) { + setStreaming(true); + return; + } + + clearThinking(); + }, [conversationId, notify]); + + const isPlanning = conversation?.agent_status === "planning" || streaming; + + // 整理方案在 Celery:靠 poll 续进度。刷新/重进只要 agent_status=planning 就会接着转。 + useEffect(() => { + if (!isPlanning) return; + let cancelled = false; + const pull = () => { + api + .getCreation(conversationId) + .then((detail) => { + if (cancelled) return; + mergeCreationDetail(detail); + }) + .catch(() => { + /* 轮询失败静默重试 */ + }); + }; + pull(); + const timer = window.setInterval(pull, AGENT_POLL_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(timer); + }; + }, [isPlanning, conversationId, mergeCreationDetail]); const send = useCallback( - async (payload: Parameters[1]) => { + async (payload: Parameters[1]) => { if (streamingRef.current) return; + // 先占位用户气泡,再亮「正在整理方案」—— 顺序不能反 + const isTextTurn = payload.kind !== "elicit_answer"; + let localId: string | null = null; + if (isTextTurn) { + const draft = makeLocalUserMessage(payload.text || "", payload.refs || []); + // 仅首页进页种子可复用同一个 local id;编辑重发必须新开气泡,即使正文相同 + const bootPending = + bootLocalIdRef.current + && pendingUserIdRef.current === bootLocalIdRef.current + && isLocalUserId(bootLocalIdRef.current) + ? bootLocalIdRef.current + : null; + localId = bootPending || draft.id; + const optimistic = bootPending + ? makeLocalUserMessage(payload.text || "", payload.refs || [], bootPending) + : draft; + pendingUserIdRef.current = localId; + setPendingUserId(localId); + pendingSinceSeqRef.current = lastKnownServerSeq(messagesRef.current); + setMessages((prev) => { + // 只按精确 localId 更新(进页种子);绝不按正文去重历史用户消息 + if (prev.some((m) => m.id === localId)) { + return prev.map((m) => + m.id === localId + ? { ...optimistic, clientKey: m.clientKey || optimistic.clientKey } + : m + ); + } + return [...prev, optimistic]; + }); + } + stalePlanningNotifiedRef.current = false; + userCancelledRef.current = false; streamingRef.current = true; setStreaming(true); setLiveText(""); setLiveReasoning(""); setActiveTool(""); - const isTextTurn = payload.kind !== "elicit_answer"; - let localId: string | null = null; - if (isTextTurn) { - localId = `local-user-${Date.now()}`; - pendingUserIdRef.current = localId; - setPendingUserId(localId); - const optimistic: CreationMessage = { - id: localId, - role: "user", - kind: "text", - text: payload.text || "", - payload: {}, - refs: payload.refs || [], - task: null, - seq: Number.MAX_SAFE_INTEGER, - created_at: new Date().toISOString(), - }; - setMessages((prev) => [...prev, optimistic]); - } - const controller = new AbortController(); - abortRef.current = controller; try { - await api.creationSendStream( - conversationId, - { ...payload }, - (event) => { - applyEvent(event); - if (event.type === "error") notify("error", String(event.detail || "生成失败")); - }, - controller.signal + const result = await api.creationSend(conversationId, { ...payload }); + if (result.messages?.length) { + setMessages((prev) => { + let next = prev; + for (const message of result.messages || []) { + next = collapseDupUser(replaceLocalUser(next, message)); + } + return next; + }); + if (localId && pendingUserIdRef.current === localId) { + pendingUserIdRef.current = null; + setPendingUserId(null); + } + } + // 用户在 await 期间点了终止:若服务端已入队 planning,补一刀 cancel,不再点亮 thinking + if (userCancelledRef.current) { + holdPlanningUntilRef.current = 0; + streamingRef.current = false; + setStreaming(false); + setConversation((prev) => + prev ? { ...prev, agent_status: "idle" } : prev + ); + if (result.agent_status === "planning") { + void api.cancelCreationAgent(conversationId).catch(() => { + /* 已 idle / 竞态忽略 */ + }); + } + return; + } + setConversation((prev) => + prev + ? { ...prev, agent_status: result.agent_status || prev.agent_status } + : prev ); + const planning = result.agent_status === "planning"; + if (planning) { + // 发出后短时间忽略轮询里的陈旧 idle,避免 thinking 闪灭 + holdPlanningUntilRef.current = Date.now() + 6000; + streamingRef.current = true; + setStreaming(true); + } else { + holdPlanningUntilRef.current = 0; + streamingRef.current = false; + setStreaming(false); + setLiveText(""); + setLiveReasoning(""); + setActiveTool(""); + } + api + .getCreation(conversationId) + .then((detail) => mergeCreationDetail(detail, localId, payload.text || "")) + .catch(() => { + /* agent poll 会接着拉 */ + }); } catch (error) { if (localId) { setMessages((prev) => prev.filter((m) => m.id !== localId)); @@ -1010,39 +1639,55 @@ export function OmniSessionPage({ setPendingUserId(null); } } - if ((error as Error).name !== "AbortError") { - notify("error", (error as Error).message); - } - } finally { streamingRef.current = false; setStreaming(false); setLiveText(""); setLiveReasoning(""); setActiveTool(""); - abortRef.current = null; - // 流结束再拉一次:worker 可能已经把 GENERATING 改成 RESULT, - // SSE 里只有「已提交」没有成图。漏了这次,页面会停在生成中。 - api - .getCreation(conversationId) - .then((detail) => { - setConversation(detail); - setMessages((prev) => keepUnackedLocals(prev, detail.messages)); - if (localId && pendingUserIdRef.current === localId) { - const acked = detail.messages.some((m) => m.role === "user" && m.text === (payload.text || "")); - if (acked) { - pendingUserIdRef.current = null; - setPendingUserId(null); - } - } - }) - .catch(() => { - /* 轮询还会再拉,这里失败不打扰 */ - }); + const status = (error as { status?: number }).status; + notify(status === 409 ? "info" : "error", (error as Error).message); } }, - [conversationId, applyEvent, notify] + [conversationId, mergeCreationDetail, notify] ); + const handleStop = useCallback(async () => { + if (stopping || !isPlanning) return; + setStopping(true); + userCancelledRef.current = true; + holdPlanningUntilRef.current = 0; + // 先本地收态,再打 API —— 离开/重进靠服务端 cancel 标 + idle + streamingRef.current = false; + setStreaming(false); + setLiveText(""); + setLiveReasoning(""); + setActiveTool(""); + setConversation((prev) => + prev ? { ...prev, agent_status: "idle" } : prev + ); + try { + await api.cancelCreationAgent(conversationId); + notify("success", "已终止"); + } catch (error) { + const status = (error as { status?: number }).status; + if (status === 409) { + // 已结束或竞态完成 —— 保持 idle,不刷错误 + notify("info", "已终止"); + } else { + userCancelledRef.current = false; + notify("error", (error as Error).message || "终止失败"); + try { + const detail = await api.getCreation(conversationId); + mergeCreationDetail(detail); + } catch { + /* 恢复失败则等用户刷新 */ + } + } + } finally { + setStopping(false); + } + }, [stopping, isPlanning, conversationId, notify, mergeCreationDetail]); + const handleConfirm = async (message: CreationMessage, nextParams: Record) => { if (confirming) return; setConfirming(true); @@ -1095,7 +1740,7 @@ export function OmniSessionPage({ setMentionLoading(true); setMentionResults([]); try { - const res = await api.searchMentions({ q: query, types: [type], limit: 12 }); + const res = await api.searchMentions({ q: query, types: [type], limit: 24 }); setMentionResults(mergeSessionUploads(res.results, sessionUploads, query, type)); setTypeLabels(res.type_labels); } catch (error) { @@ -1137,7 +1782,7 @@ export function OmniSessionPage({ case "elicit": return ( @@ -1146,13 +1791,13 @@ export function OmniSessionPage({ /> ); case "strategy": - return ; + return ; case "plan": - return ; + return ; case "prompt_file": return ( setPromptView({ title: String(message.payload.title || "视频生成Prompt.md"), @@ -1163,7 +1808,7 @@ export function OmniSessionPage({ case "confirm": return ( ); case "generating": - return ; + return ; case "result": - return ; + return ; default: { const refs = message.refs || []; const body = stripMentionText(message.text, refs); @@ -1183,7 +1828,7 @@ export function OmniSessionPage({ return (
{message.role !== "user" && ( @@ -1191,10 +1836,35 @@ export function OmniSessionPage({ )}
-
- {message.role === "user" ? : null} - {body ?

{body}

: null} -
+ {message.role === "user" ? : null} + {(body || message.role !== "user") ? ( +
+ {body ?

{body}

: null} +
+ ) : null} + {message.role === "user" && !pending ? ( +
+ + + +
+ ) : null}
); @@ -1243,7 +1913,7 @@ export function OmniSessionPage({ {/* 流式中的临时气泡。挂 is-live 关掉入场动画 —— 它每来一个字符都会 重渲染,带动画的话整条会一直在闪;真消息落地时它被替换掉, 那一下也不该再演一次入场。 */} - {streaming && !hasGenerating ? ( + {(streaming || conversation?.agent_status === "planning") && !hasGenerating ? ( /生成图片|生成视频/.test(activeTool) && !liveText ? ( ) : liveText ? ( @@ -1255,27 +1925,17 @@ export function OmniSessionPage({

{liveText}

- ) : liveReasoning ? ( -
- - - -
-
- - {activeTool ? `${activeTool}…` : "正在整理方案"} -
-

{liveReasoning}

-
-
) : (
- ) @@ -1290,6 +1950,7 @@ export function OmniSessionPage({ />