diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index 299b323..b58dc5d 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -184,6 +184,22 @@ _CHITCHAT_RE = re.compile( ) +_GREETING_RE = re.compile( + r"^\s*(" + r"hi|hello|hey|yo|hola|" + r"你好呀?|您好|嗨|哈喽|嘿|" + r"在吗|在不在|有人吗|" + r"早+|早安|早上好|午安|晚安" + r")[\s!!.。~~??…]*$", + re.IGNORECASE, +) + + +def is_greeting(user_text: str) -> bool: + """单纯打招呼要立刻回应,不能为了分析引用素材去等模型。""" + return bool(_GREETING_RE.match((user_text or "").strip())) + + def is_pure_chitchat(user_text: str) -> bool: """纯打招呼 / 应答,没有创作意图。这类消息绝不能触发 write_strategy / write_plan。""" text = (user_text or "").strip() @@ -268,7 +284,7 @@ def session_has_creative_context(conversation: CreationConversation) -> bool: def wanted_asset_pick(user_text: str, refs: list | None) -> str | None: - """用户说「改商品」却没点名时,直接发对应素材选择卡。""" + """用户说「改商品」却没点名时,启动对应素材的自然确认。""" if refs: return None text = user_text or "" @@ -464,8 +480,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "缺少必要信息时向用户追问。" "只在信息**确实缺失且无法合理推断**时用;能自己定的就自己定,别把用户当填表机器。" "用户要选/改/换商品、角色、模特、场景时**必须**调这个工具," - "type 用 asset 并填 asset_types,系统会直接显示可视化素材卡让用户点选。" - "不要先问用户是否需要卡片或列表。其他类型才显示为普通聊天问题。" + "type 用 asset 并填 asset_types。系统会以类Agent自然对话轻量询问是否需要发送商品列表(同时支持直接输入名字或由你推荐),避免一上来粗暴弹出大卡片打断交流。" "一次只问 1 项,禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。" ), "parameters": { @@ -1044,7 +1059,9 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "- 不要逐字复述用户刚说过的话。需要承接时,只说你的判断或下一步,例如「这个方向能做,我先把反转落在商品登场上。」", "- 一次只推进一步,但不要把能直接做的事停在寒暄、确认或客套话上。", "- 缺信息时一次只问一个真正影响结果的问题,不要把对话做成问卷。", - "- 商品、模特、角色、场景这类有库内素材可选的内容,直接发可视化选择卡让用户点;不要先问「要不要发列表」。", + "- 缺少商品、模特、角色、场景等素材时:**绝不要直接弹出大块素材选择卡**打断对话。像懂创作、懂电商的专业伙伴一样先自然询问用户想推什么商品/用哪个角色,询问是否需要把商品列表发给他选,同时说明也可以直接输入商品名或由你推荐。", + "- 只有当用户明确说要发列表(如「发给我」「发列表」「给我看看」「我来选」)时,才展示可视化卡片。", + "- 用户直接打字输入商品名时,直接采纳该商品并继续推进创作方案,不要强迫用户去卡片里点选。", "- 调性、受众、文案、时长等其他信息才用聊天追问,问题末尾顺手告诉用户下一句怎么回。", "- 用户刚回答过你的问题时,直接沿着答案继续;不要复述答案,也不要额外回一句「收到」。", "- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。", @@ -1058,10 +1075,10 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "", "【什么时候反问】", "- 只有信息**确实缺失且无法合理推断**时才调用 ask_user;能自己定的就自己定。", - "- ask_user 一次只问一件事。type=asset 会显示素材选择卡;其他类型显示普通聊天问题,让用户直接输入。", + "- ask_user 一次只问一件事。type=asset 会以类Agent自然追问轻量询问是否发送列表或由你推荐;其他类型显示普通聊天问题,让用户直接输入。", "- 商品是谁、给谁看、什么调性 —— 这些缺了会直接影响成片,值得问。", "- 让用户选商品/角色/模特/场景时,ask_user 必须用 type=asset 并填 asset_types。", - "- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 发对应素材卡,不要再用文字追问名称。", + "- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 启动素材确认,不要强行出大卡片。", "- 用户说改时长/模型/比例/分辨率但没给新值:立刻 ask_user,type=single 提供可识别的候选值;聊天里只显示问题,用户直接输入。回答后旧方案作废,必须按新参数重新 write_plan。", "- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。", ] @@ -1181,8 +1198,12 @@ def build_messages(context: AgentContext, *, allow_plan: bool = True, has_contex choice = str(answers.get("_asset_gate") or "") if choice == "skip": answer_text = "(用户选择暂不添加这项素材,要求按现有信息继续原任务)" - else: + elif choice == "auto": + answer_text = "(用户希望你帮忙挑选/决定素材,按合理推荐继续推进)" + elif choice == "send": answer_text = "(用户希望打开素材列表继续选择)" + else: + answer_text = f"(用户指定了素材:{choice})" else: joined = ";".join( f"{field_by_key.get(str(key), {}).get('label') or key}:" @@ -1252,12 +1273,15 @@ def stream_creation_agent( ) -> Iterator[str]: """一条用户消息 → SSE 流。生成器,由 StreamingHttpResponse 逐帧下发。""" refs = refs or [] - model_config = get_creation_chat_model(model_config) - if model_config is None: - yield _sse({"type": "error", "detail": "没有可用的文本模型,请先在模型库配置"}) - return + 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": "没有可用的文本模型,请先在模型库配置"}) + return + context = AgentContext(conversation=conversation, user=user, model_config=model_config) - context = AgentContext(conversation=conversation, user=user, model_config=model_config) try: user_message = None with transaction.atomic(): @@ -1266,8 +1290,23 @@ def stream_creation_agent( 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)}) - # 空会话里的纯打招呼:短回一句就够。已有商品/方向时走模型, - # 针对用户实际说的话自然回应,但不给方案工具。 + # 单纯打招呼无论有没有参考素材、有没有既有上下文,都不值得等模型。 + # 素材先收下,等用户说清用途再分析,避免「你好」卡住还冒出一大段建议。 + if fast_greeting: + reply = append_message( + conversation, + role="assistant", + text=( + "你好,素材我收到了。想拿它做什么,直接说就行。" + if refs else "你好。想做什么,直接说就行。" + ), + ) + yield _sse({"type": "message", "message": _message_payload(reply)}) + yield _sse({"type": "done"}) + return + + # 空会话里的简短应答:短回一句就够。已有商品/方向时走模型, + # 针对用户实际说的话自然回应,但不给方案工具。 has_context = session_has_creative_context(conversation) if record_user_message and is_pure_chitchat(text) and not refs and not has_context: reply = append_message( @@ -1330,22 +1369,38 @@ def stream_creation_agent( said = "".join(text_buffer).strip() calls = [tool_buffer[i] for i in sorted(tool_buffer) if tool_buffer[i].get("name")] fallback_fields = None + allow_pick = False if not calls: - pick = ( - wanted_asset_pick(text, refs) - or requested_asset_card_from_context(conversation, text) - ) + requested_card = requested_asset_card_from_context(conversation, text) + wanted_pick = wanted_asset_pick(text, refs) + pick = requested_card or wanted_pick param_keys = wanted_param_keys(text, is_video=context.is_video) if pick: fallback_fields = [{ "key": pick, - "label": _ASSET_PICK_LABEL[pick], + "label": _ASSET_PICK_LABEL.get(pick, _ASSET_CARD_LABELS.get(pick, "请选择素材")), "type": "asset", "required": True, "asset_types": [pick], }] + allow_pick = bool(requested_card) elif param_keys: fallback_fields = session_param_fields(param_keys, context.is_video) + elif allow_plan and not said: + # 部分推理模型会只给 reasoning_content 后结束本轮。不能让用户 + # 只看到自己那条消息;没有商品上下文时,收敛成一条自然追问。 + has_product = any( + isinstance(ref, dict) and ref.get("type") == "product" + for ref in (conversation.pinned_refs or []) + ) + if not has_product: + fallback_fields = [{ + "key": "product", + "label": "这条想推哪款商品?", + "type": "asset", + "required": True, + "asset_types": ["product"], + }] # ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再 # 另存一条,否则界面会连续出现两遍几乎相同的问题。 @@ -1356,7 +1411,9 @@ def stream_creation_agent( if not calls: if fallback_fields: - result, _stop = _dispatch_tool(context, "ask_user", {"fields": fallback_fields}) + result, _stop = _dispatch_tool( + context, "ask_user", {"fields": fallback_fields}, allow_pick=allow_pick + ) for event in result.get("_events", []): yield _sse(event) break @@ -1422,11 +1479,19 @@ _ASSET_CARD_LABELS = { "asset": "这一步要使用哪项素材?", } +_GATE_LABELS = { + "product": "这条想推哪款商品?可以直接告诉我商品名,或者需要我把商品库列表发给你选吗?", + "character": "这条想用哪个角色?可以直接告诉我角色名,或者需要我发角色列表给你选吗?", + "model": "这条想用哪位模特?可以直接告诉我,或者需要我把模特库发给你选吗?", + "scene": "这条想放在哪个场景里?可以直接告诉我,或者需要我把场景库发给你选吗?", + "asset": "这一步想用哪项素材?可以直接告诉我,或者需要我把素材列表发给你选吗?", +} + def _guided_elicit_text(field: dict) -> str: """追问既要问清一件事,也要让用户知道下一句怎么回。""" label = str(field.get("label") or "这项你想怎么定?").strip() - if field.get("type") == "asset": + if field.get("type") == "asset" or field.get("key") == "_asset_gate": return label if str(field.get("key") or "") in SESSION_PARAM_KEYS: return label @@ -1434,16 +1499,46 @@ def _guided_elicit_text(field: dict) -> str: -def _elicit_payload_for_fields(fields: list[dict]) -> dict: - """素材问题直接出选择卡;其他问题继续走普通聊天。""" +def _elicit_payload_for_fields( + fields: list[dict], *, allow_pick: bool = False, is_video: bool = True +) -> dict: + """素材问题默认出轻量对话闸门;用户明确要求发列表时才出 pick 选择卡。""" field = dict(fields[0]) if field.get("type") == "asset": - asset_types = [item for item in (field.get("asset_types") or []) if item in _ASSET_CARD_LABELS] - field["label"] = _ASSET_CARD_LABELS[asset_types[0]] if len(asset_types) == 1 else _ASSET_CARD_LABELS["asset"] + asset_types = [item for item in (field.get("asset_types") or []) if item in _GATE_LABELS] + primary_type = asset_types[0] if asset_types else "product" + if allow_pick: + field["label"] = _ASSET_CARD_LABELS.get(primary_type, _ASSET_CARD_LABELS["asset"]) + return { + "interaction": "asset_picker", + "phase": "pick", + "fields": [field], + "submitted": False, + "answers": {}, + } + + if primary_type == "product": + gate_label = ( + "这条视频想推哪款商品?可以直接告诉我商品名,或者需要我把商品库列表发给你选吗?" + if is_video else + "这次想做哪款商品?可以直接告诉我商品名,或者需要我把商品库列表发给你选吗?" + ) + else: + gate_label = _GATE_LABELS.get(primary_type, _GATE_LABELS["asset"]) + + gate_field = { + "key": "_asset_gate", + "label": gate_label, + "type": "text", + "required": False, + } return { - "interaction": "asset_picker", - "phase": "pick", - "fields": [field], + # 这是 Agent 的一句自然追问,不是让用户点选流程的卡片。 + # 只有用户明确要求发列表后,才会生成上面 allow_pick 分支的商品卡。 + "interaction": "chat", + "phase": "gate", + "fields": [gate_field], + "pending_fields": [field], "submitted": False, "answers": {}, } @@ -1455,7 +1550,9 @@ def _elicit_payload_for_fields(fields: list[dict]) -> dict: } -def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict, bool]: +def _dispatch_tool( + context: AgentContext, name: str, args: dict, *, allow_pick: bool = False +) -> tuple[dict, bool]: """执行一个工具。返回 (结果, 是否中断循环)。 结果里的 `_events` 会原样转发给前端,`payload` 回喂给模型。 @@ -1464,7 +1561,7 @@ def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict, fields = _coerce_fields(args.get("fields")) if not fields: return {"payload": {"error": "fields 不合法,请重新组织问题"}}, False - payload = _elicit_payload_for_fields(fields) + payload = _elicit_payload_for_fields(fields, allow_pick=allow_pick, is_video=context.is_video) display_field = (payload.get("fields") or fields)[0] message = append_message( context.conversation, role="assistant", diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index ddcb6da..8835dc0 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -27,6 +27,7 @@ from .creation_agent import ( get_creation_chat_model, has_creative_intent, is_continue_intent, + is_greeting, is_pure_chitchat, session_has_creative_context, stream_creation_agent, @@ -205,12 +206,12 @@ class AskUserTests(CreationAgentBaseTests): elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"] self.assertEqual(len(elicit), 1) payload = elicit[0]["message"]["payload"] - self.assertEqual(payload["interaction"], "asset_picker") - self.assertEqual(payload["phase"], "pick") - self.assertEqual(payload["fields"][0]["type"], "asset") - self.assertEqual(payload["fields"][0]["asset_types"], ["product"]) - self.assertEqual(payload["fields"][0]["label"], "这条要展示哪款商品?") - self.assertEqual(elicit[0]["message"]["text"], "这条要展示哪款商品?") + self.assertEqual(payload["interaction"], "chat") + self.assertEqual(payload["phase"], "gate") + self.assertEqual(payload["fields"][0]["key"], "_asset_gate") + self.assertEqual(payload["pending_fields"][0]["type"], "asset") + self.assertEqual(payload["pending_fields"][0]["asset_types"], ["product"]) + self.assertIn("商品库列表", elicit[0]["message"]["text"]) def test_requesting_the_card_uses_the_previous_asset_context(self): append_message(self.conversation, role="user", text="帮我做一条商品短片") @@ -715,6 +716,112 @@ class SendEndpointTests(TestCase): for event in events if event.get("type") == "message" )) + def test_gate_emits_conversational_question_not_direct_pick_card(self): + """用户发消息缺商品时,先出轻量对话闸门询问是否发列表,不直接出大卡片。""" + fake = FakeProvider([_tool_chunks("ask_user", {"fields": [ + {"key": "product", "label": "这次主推哪个商品?", "type": "asset", "asset_types": ["product"]}, + ]})]) + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"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.assertEqual(len(elicit), 1) + payload = elicit[0]["message"]["payload"] + 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"]) + + def test_typing_yes_in_chat_triggers_pick_card(self): + """在「要不要发列表」闸门挂起时,用户回「需要」直接弹出 pick 卡片。""" + append_message(self.conversation, role="user", text="我想做一条带货视频") + card = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="这条视频想推哪款商品?可以直接告诉我商品名,或者需要我把商品库列表发给你选吗?", + payload={ + "interaction": "asset_picker", + "phase": "gate", + "fields": [{ + "key": "_asset_gate", + "label": "这条视频想推哪款商品?", + "type": "single", + "options": [{"value": "send", "label": "发商品列表我选"}], + }], + "pending_fields": [{ + "key": "product", + "label": "选哪个商品?", + "type": "asset", + "asset_types": ["product"], + }], + "submitted": False, + "answers": {}, + }, + ) + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"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" + ] + self.assertEqual(len(pick_cards), 1) + self.assertEqual(pick_cards[0]["payload"]["fields"][0]["type"], "asset") + + def test_typing_product_name_in_chat_answers_gate(self): + """在闸门挂起时,用户直接输入商品名,直接绑定商品并推进。""" + product = Product.objects.create(team=self.team, created_by=self.user, title="清爽控油洗面奶") + append_message(self.conversation, role="user", text="我想做一条带货视频") + card = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text="这条视频想推哪款商品?", + payload={ + "interaction": "asset_picker", + "phase": "gate", + "fields": [{"key": "_asset_gate", "label": "想推哪款商品?", "type": "single"}], + "pending_fields": [{"key": "product", "label": "选商品", "type": "asset", "asset_types": ["product"]}], + "submitted": False, + "answers": {}, + }, + ) + fake = FakeProvider([_text_chunks("好的,我按清爽控油洗面奶写方案。")]) + with patch("apps.ai.creation_agent.build_provider", return_value=fake): + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"kind": "text", "text": f"就推{product.title}吧"}, + format="json", + ) + list(response.streaming_content) + + card.refresh_from_db() + self.conversation.refresh_from_db() + self.assertTrue(card.payload["submitted"]) + self.assertTrue(any( + ref.get("type") == "product" and str(ref.get("id")) == str(product.id) + for ref in self.conversation.pinned_refs + )) + class VideoPlanAndConfirmTests(CreationAgentBaseTests): """视频链路:策略卡 → 方案卡 → 确认闸门 → 出片(契约 §0)。""" @@ -1173,6 +1280,12 @@ class PureChitchatTests(SimpleTestCase): for text in ("hi", "Hi!", "你好", "在吗", "嗯", "好的", "ok", "谢谢"): self.assertTrue(is_pure_chitchat(text), text) + def test_greetings_are_distinguished_from_other_short_replies(self): + for text in ("你好", "在吗", "hello", "早上好"): + self.assertTrue(is_greeting(text), text) + for text in ("嗯", "好的", "谢谢", "做条视频"): + self.assertFalse(is_greeting(text), text) + def test_creative_asks_are_not_chitchat(self): for text in ("做条带货视频", "hi 帮我改个方案", "换个商品", "重新写方案"): self.assertFalse(is_pure_chitchat(text), text) @@ -1221,9 +1334,42 @@ class ToolGateTests(CreationAgentBaseTests): for event in events if event.get("type") == "message" and event["message"]["role"] == "assistant" ] - self.assertEqual(replies, ["我在。想做什么,直接丢一句想法给我就行。"]) + self.assertEqual(replies, ["你好。想做什么,直接说就行。"]) self.assertNotIn("有需要再说", replies[0]) + def test_greeting_with_reference_returns_immediately_without_image_analysis(self): + events, fake = self._run( + [_text_chunks("这段不该出现")], + text="你好", + refs=[{"type": "product", "id": "missing-product", "name": "参考商品"}], + ) + replies = [ + event["message"]["text"] + for event in events + if event.get("type") == "message" and event["message"]["role"] == "assistant" + ] + self.assertEqual(replies, ["你好,素材我收到了。想拿它做什么,直接说就行。"]) + self.assertEqual(fake.calls, []) + + def test_reasoning_only_creative_turn_never_leaves_user_without_a_reply(self): + def reasoning_only(): + yield {"type": "reasoning", "text": "先分析一下"} + yield {"type": "done"} + + events, _ = self._run( + [reasoning_only()], + text="把商品塑造成有性格的拟人角色,做一条轻快短广告", + ) + elicit = [ + event["message"] + for event in events + if event.get("type") == "message" and event["message"]["kind"] == "elicit" + ] + self.assertEqual(len(elicit), 1) + self.assertEqual(elicit[0]["payload"]["interaction"], "chat") + self.assertEqual(elicit[0]["payload"]["phase"], "gate") + self.assertIn("商品库列表", elicit[0]["text"]) + def test_idle_chat_hides_plan_tools(self): fake = FakeProvider([_text_chunks("哈哈,有需要再说")]) with patch("apps.ai.creation_agent.build_provider", return_value=fake): diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index aa09eeb..967107a 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -22,7 +22,17 @@ 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_agent import apply_confirm_params, apply_session_params, stream_creation_agent, submit_confirmed_image, submit_confirmed_video, _sse, _message_payload +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 .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig from .serializers import ( @@ -105,6 +115,14 @@ def _chat_answer_for_field(field: dict, text: str, team=None): return raw +_WANTS_CARD_RE = re.compile( + r"(发|打开|展示|看看|看下|给我|发我|发来).{0,10}(卡片|列表|商品库|素材库|库|选项)?" + r"|(卡片|列表).{0,10}(选|选择|看看|看下)?" + r"|^(需要|要|需要的|要的|可以|行|好|好的|发|发我|发来|发给我|发出来|打开|打开列表|看下|看下列表|发列表|选商品|选素材|你发吧|好的发吧|发吧|发给我选)[吧呀呢啊!!.。 ]*$", + re.IGNORECASE, +) + + def _pending_chat_question(conversation: CreationConversation) -> CreationMessage | None: """找最近一条未回答的新式追问;卡片既可点选,也兼容直接输入。""" candidates = conversation.messages.filter( @@ -112,7 +130,7 @@ def _pending_chat_question(conversation: CreationConversation) -> CreationMessag ).order_by("-seq")[:20] for message in candidates: payload = message.payload or {} - if payload.get("interaction") in {"chat", "asset_picker"} and not payload.get("submitted"): + if not payload.get("submitted"): return message return None @@ -1465,41 +1483,159 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): if not isinstance(refs, list): return JsonResponse({"detail": "refs 必须是数组"}, status=400) - if kind == "text" and text: + # 打招呼不应被误当成上一条素材追问的答案;让 agent 立刻简短回应即可。 + if kind == "text" and text and not is_greeting(text): pending = _pending_chat_question(conversation) if pending is not None: payload = dict(pending.payload or {}) - fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)] - if fields: - field = fields[0] - answers = { - str(field.get("key") or "answer"): - _chat_answer_for_field(field, text, conversation.team) - } - payload["answers"] = answers - payload["submitted"] = True - payload["answered_via"] = "chat" - pending.payload = payload - pending.save(update_fields=["payload", "updated_at"]) + if payload.get("phase") == "gate": + pending_fields = [item for item in (payload.get("pending_fields") or []) if isinstance(item, dict)] + primary_field = pending_fields[0] if pending_fields else {} + asset_types = [t for t in (primary_field.get("asset_types") or []) if t in TYPE_LABELS] or ["product"] - params_changed = apply_session_params(conversation, fields, answers) - existing = { - (item.get("type"), str(item.get("id"))) - for item in refs if isinstance(item, dict) - } - for extra in refs_from_elicit_answers(conversation.team, fields, answers): - mark = (extra.get("type"), str(extra.get("id"))) - if mark not in existing: - refs.append(extra) - existing.add(mark) + # 1. 用户明确在打字说「发列表/发给我/发卡片/打开列表」 + if _WANTS_CARD_RE.search(text): + payload["answers"] = {"_asset_gate": "send"} + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) - force_creative_turn = True - continuation_instruction = ( - "用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;" - "不要复述答案,不要回复收到,也不要问要不要继续或要不要生成。" - ) - if params_changed: - continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。" + user_msg = append_message(conversation, role="user", text=text) + pick_field = dict(primary_field) + pick_field["label"] = _ASSET_CARD_LABELS.get( + asset_types[0], _ASSET_CARD_LABELS.get("asset", "请选择素材") + ) + pick = append_message( + conversation, + role="assistant", + kind=CreationMessage.Kind.ELICIT, + text=pick_field["label"], + payload={ + "interaction": "asset_picker", + "phase": "pick", + "fields": [pick_field], + "submitted": False, + "answers": {}, + }, + ) + + 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 + + # 2. 用户说「你帮我选/你定/随便」 + elif re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行|你挑)", text): + hits = search_mentions(conversation.team, q="", types=asset_types, limit=1) + chosen_name = hits[0]["name"] if hits else "推荐商品" + if hits: + mark = (hits[0].get("type"), str(hits[0].get("id"))) + existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)} + if mark not in existing: + refs.append(hits[0]) + payload["answers"] = {"_asset_gate": "auto", str(primary_field.get("key") or "product"): chosen_name} + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) + + force_creative_turn = True + if hits: + continuation_instruction = ( + f"用户让你帮忙挑选,系统已为你选定【{hits[0]['name']}】。直接基于该素材推进创作方案,不要复述选项,不要重复追问。" + ) + else: + continuation_instruction = ( + "用户让你帮忙定素材,目前素材库暂无已上传素材。请结合所选预设与合理电商默认设想一款匹配的商品继续推进创作方案,不要重复追问。" + ) + + # 3. 用户说「先不用/不选了/跳过」 + elif re.search(r"(先不用|不用|不选|先不选|跳过|暂不|没有商品|不需要)", text): + payload["answers"] = {"_asset_gate": "skip"} + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) + + force_creative_turn = True + continuation_instruction = ( + "用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。" + "先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。" + ) + + # 4. 用户直接输入了商品名或其它内容 + else: + hits = search_mentions(conversation.team, q="", types=asset_types, limit=50) + text_norm = _normalise_chat_choice(text) + matched_hit = None + for hit in hits: + hname = _normalise_chat_choice(hit.get("name") or "") + if hname and (hname in text_norm or text_norm in hname): + matched_hit = hit + break + + if matched_hit: + mark = (matched_hit.get("type"), str(matched_hit.get("id"))) + existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)} + if mark not in existing: + refs.append(matched_hit) + chosen_name = matched_hit["name"] + continuation_instruction = ( + f"用户已指定使用素材【{chosen_name}】。直接基于该素材推进创作方案,不要复述选项,不要重复追问。" + ) + else: + chosen_name = text + continuation_instruction = ( + f"用户已指定推广内容为「{text}」。直接围绕该内容推进创作方案,不要重复追问。" + ) + + payload["answers"] = {"_asset_gate": chosen_name, str(primary_field.get("key") or "product"): chosen_name} + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) + force_creative_turn = True + + else: + fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)] + if fields: + field = fields[0] + answers = { + str(field.get("key") or "answer"): + _chat_answer_for_field(field, text, conversation.team) + } + payload["answers"] = answers + payload["submitted"] = True + payload["answered_via"] = "chat" + pending.payload = payload + pending.save(update_fields=["payload", "updated_at"]) + + params_changed = apply_session_params(conversation, fields, answers) + existing = { + (item.get("type"), str(item.get("id"))) + for item in refs if isinstance(item, dict) + } + for extra in refs_from_elicit_answers(conversation.team, fields, answers): + mark = (extra.get("type"), str(extra.get("id"))) + if mark not in existing: + refs.append(extra) + existing.add(mark) + + force_creative_turn = True + continuation_instruction = ( + "用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;" + "不要复述答案,不要回复收到,也不要问要不要继续或要不要生成。" + ) + if params_changed: + continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。" if kind == "confirm": reply_to = str(request.data.get("reply_to") or "").strip() @@ -1575,13 +1711,21 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): choice = str(answers.get("_asset_gate") or "").strip() if choice == "send": pending = payload.get("pending_fields") or [] + primary_field = pending[0] if pending else {} + types = [t for t in (primary_field.get("asset_types") or []) if t in TYPE_LABELS] or ["product"] + pick_field = dict(primary_field) + pick_field["label"] = _ASSET_CARD_LABELS.get( + types[0], _ASSET_CARD_LABELS.get("asset", "请选择素材") + ) pick = append_message( conversation, role="assistant", kind=CreationMessage.Kind.ELICIT, + text=pick_field["label"], payload={ + "interaction": "asset_picker", "phase": "pick", - "fields": pending, + "fields": [pick_field], "submitted": False, "answers": {}, }, @@ -1597,15 +1741,34 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): response["Cache-Control"] = "no-cache" response["X-Accel-Buffering"] = "no" return response - # 卡片本身已经记录了用户的选择。不要再伪造一条黑色用户气泡; - # 继续原任务,并明确告诉模型不要再次追问同一项素材。 - text = "" - record_user_message = False - force_creative_turn = True - continuation_instruction = ( - "用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。" - "先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。" - ) + elif choice == "auto": + pending = payload.get("pending_fields") or [] + primary_field = pending[0] if pending else {} + types = [item for item in (primary_field.get("asset_types") or []) if item in TYPE_LABELS] or ["product"] + hits = search_mentions(conversation.team, q="", types=types, limit=1) + if hits: + refs = list(refs) + refs.append(hits[0]) + continuation_instruction = ( + f"用户希望你帮忙挑选素材,已为你选定【{hits[0]['name']}】。直接基于该素材推进创作方案,不要复述选项,不要重复追问。" + ) + else: + continuation_instruction = ( + "用户希望你帮忙定素材,目前素材库暂无已上传素材。请结合所选预设与合理电商默认设想一款匹配的商品继续推进创作方案,不要重复追问。" + ) + text = "" + record_user_message = False + force_creative_turn = True + else: + # 卡片本身已经记录了用户的选择。不要再伪造一条黑色用户气泡; + # 继续原任务,并明确告诉模型不要再次追问同一项素材。 + text = "" + record_user_message = False + force_creative_turn = True + continuation_instruction = ( + "用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。" + "先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。" + ) else: params_changed = apply_session_params(conversation, payload.get("fields") or [], answers) # 点选商品/角色必须钉成 Ref:模型常把选项做成单选文字,前端只回 answers。 diff --git a/core/backend/apps/projects/test_quick_create.py b/core/backend/apps/projects/test_quick_create.py index 7b14731..d2747df 100644 --- a/core/backend/apps/projects/test_quick_create.py +++ b/core/backend/apps/projects/test_quick_create.py @@ -1368,3 +1368,5 @@ class QuickCreateCoordinatorTests(TestCase): + + diff --git a/core/frontend/src/omni-session-page.css b/core/frontend/src/omni-session-page.css index 4b32969..461a4e4 100644 --- a/core/frontend/src/omni-session-page.css +++ b/core/frontend/src/omni-session-page.css @@ -1463,25 +1463,29 @@ color: #66707d; } -.omni-send-pending { - display: inline-flex; - align-items: center; - gap: 6px; - color: rgba(255, 255, 255, .72); - font-size: 12px; +.omni-chat-stack { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + max-width: min(670px, 82%); } -.omni-send-spinner, -.omni-typing i { - width: 10px; - height: 10px; - border-radius: 50%; +.omni-chat-stack .omni-chat-bubble { + max-width: 100%; +} + +/* 发送中:不要文案;用户气泡略提亮 + 柔和呼吸,发出去后恢复纯黑 */ +.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 { - border: 1.5px solid rgba(255, 255, 255, .28); - border-top-color: #fff; - animation: omniSpin .7s linear infinite; + width: 10px; + height: 10px; + border-radius: 50%; } .omni-chat-bubble.is-thinking { @@ -1502,6 +1506,7 @@ .omni-typing i { width: 6px; height: 6px; + border-radius: 50%; background: var(--klein); opacity: .35; animation: omniTyping 1.1s ease-in-out infinite; @@ -1515,19 +1520,6 @@ 40% { opacity: 1; transform: translateY(-2px); } } -.omni-process-skeleton, -.omni-think-skeleton { - height: 168px; - border-radius: var(--r-md, 12px); - background: linear-gradient(90deg, #eef1f5 0%, #f7f8fa 45%, #eef1f5 100%); - background-size: 220% 100%; - animation: omniShimmer 1.2s ease infinite; -} - -.omni-think-skeleton { - display: none; -} - .omni-chat-bubble.is-reasoning { display: flex; flex-direction: column; @@ -1557,6 +1549,25 @@ padding-left: 10px; } +@keyframes omniSendPulse { + 0%, 100% { + background: #3a424e; + box-shadow: 0 0 0 0 rgba(58, 66, 78, 0); + } + 50% { + background: #4a5360; + box-shadow: 0 0 0 4px rgba(58, 66, 78, .12); + } +} + +.omni-process-skeleton { + height: 168px; + border-radius: var(--r-md, 12px); + background: linear-gradient(90deg, #eef1f5 0%, #f7f8fa 45%, #eef1f5 100%); + background-size: 220% 100%; + animation: omniShimmer 1.2s ease infinite; +} + @keyframes omniShimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } diff --git a/core/frontend/src/routes/omni-session.tsx b/core/frontend/src/routes/omni-session.tsx index 503a467..b986c09 100644 --- a/core/frontend/src/routes/omni-session.tsx +++ b/core/frontend/src/routes/omni-session.tsx @@ -326,14 +326,16 @@ function ElicitCard({ // 素材选择闸门:先轻量询问,用户愿意时再展开真正的选择卡 if (phase === "gate") { const gateAnswer = saved._asset_gate; - const selectedLabel = gateField?.options?.find((option) => option.value === gateAnswer)?.label; + const selectedLabel = + gateField?.options?.find((option) => option.value === gateAnswer)?.label + || (gateAnswer ? String(gateAnswer) : undefined); return (
{gateLabel}
+{message.text || gateLabel}
{!submitted ? ({body}
: null} - {pending ? ( - - - 发送中 - - ) : null} +{body}
: null} +{liveText}
- ) : liveReasoning ? ( - <> -{liveReasoning}
- > - ) : ( - <> - - {activeTool ? `${activeTool}…` : isVideo ? "正在整理方案" : "正在思考"} - > - )} +{liveText}
+{liveReasoning}
+