diff --git a/app/agent/__init__.py b/app/agent/__init__.py index aeb22147e..e8598b865 100644 --- a/app/agent/__init__.py +++ b/app/agent/__init__.py @@ -318,13 +318,19 @@ class MoviePilotAgent: """ 构造可展示的 Agent 会话消息。 """ + normalized_content = content or "" return { "id": f"{role}-{uuid.uuid4().hex}", "role": role, - "content": content or "", + "content": normalized_content, "createdAt": cls._current_timestamp_ms(), "status": status, "tools": [], + "segments": ( + [{"type": "text", "content": normalized_content}] + if normalized_content + else [] + ), "attachments": attachments or [], "choices": [], } diff --git a/app/api/endpoints/agent.py b/app/api/endpoints/agent.py index 560f99b12..5b889f5db 100644 --- a/app/api/endpoints/agent.py +++ b/app/api/endpoints/agent.py @@ -318,16 +318,53 @@ async def _get_accessible_agent_chat( return chat +def _append_web_agent_text_segment(assistant_message: dict, content: str) -> None: + """ + 将文本增量追加到展示消息,并仅合并相邻文本片段。 + + :param assistant_message: 当前助手展示消息 + :param content: 新增文本 + """ + if not content: + return + assistant_message["content"] = str(assistant_message.get("content") or "") + content + segments = assistant_message.setdefault("segments", []) + if segments and segments[-1].get("type") == "text": + segments[-1]["content"] = str(segments[-1].get("content") or "") + content + else: + segments.append({"type": "text", "content": content}) + + +def _build_legacy_web_agent_segments(content: str, tools: list[dict]) -> list[dict]: + """ + 为未携带有序片段的旧展示消息生成兼容布局。 + + :param content: 聚合后的助手文本 + :param tools: 工具提示列表 + :return: 按旧版工具在前、文本在后的顺序生成的片段 + """ + segments = [ + {"type": "tool", "toolIndex": index} + for index in range(len(tools)) + ] + if content: + segments.append({"type": "text", "content": content}) + return segments + + def _apply_web_agent_display_event(event: dict, assistant_message: dict) -> None: """ 将 WebAgent SSE 事件同步应用到服务端展示消息快照。 """ event_type = event.get("type") if event_type == "delta": - assistant_message["content"] += event.get("content") or "" + _append_web_agent_text_segment( + assistant_message, event.get("content") or "" + ) elif event_type == "tool": for tool in assistant_message["tools"]: tool["status"] = "done" + tool_index = len(assistant_message["tools"]) assistant_message["tools"].append( { "id": f"tool-{uuid.uuid4().hex}", @@ -335,6 +372,9 @@ def _apply_web_agent_display_event(event: dict, assistant_message: dict) -> None "status": "running", } ) + assistant_message.setdefault("segments", []).append( + {"type": "tool", "toolIndex": tool_index} + ) elif event_type == "attachment" and event.get("attachment"): assistant_message["attachments"].append(event["attachment"]) elif event_type == "choice" and event.get("choice"): @@ -346,14 +386,22 @@ def _apply_web_agent_display_event(event: dict, assistant_message: dict) -> None assistant_message["attachments"] = target_message.get("attachments") or [] assistant_message["choices"] = target_message.get("choices") or [] assistant_message["tools"] = target_message.get("tools") or [] + target_segments = target_message.get("segments") + assistant_message["segments"] = ( + target_segments + if isinstance(target_segments, list) + else _build_legacy_web_agent_segments( + assistant_message["content"], assistant_message["tools"] + ) + ) assistant_message["status"] = target_message.get("status") or "done" elif event_type == "error": assistant_message["status"] = "error" - assistant_message["content"] = ( - assistant_message["content"] - or event.get("message") - or "智能助手响应失败" - ) + if not assistant_message["content"]: + _append_web_agent_text_segment( + assistant_message, + event.get("message") or "智能助手响应失败", + ) for tool in assistant_message["tools"]: tool["status"] = "done" elif event_type == "done": diff --git a/app/schemas/agent.py b/app/schemas/agent.py index f756506b2..144e21bab 100644 --- a/app/schemas/agent.py +++ b/app/schemas/agent.py @@ -133,6 +133,16 @@ class AgentChatToolCall(BaseModel): status: str = Field(default="done", description="工具状态") +class AgentChatMessageSegment(BaseModel): + """ + Agent 会话消息中的有序展示片段。 + """ + + type: str = Field(..., description="片段类型") + content: str = Field(default="", description="文本片段内容") + toolIndex: Optional[int] = Field(None, description="工具提示索引") + + class AgentChatChoiceButton(BaseModel): """ Agent 会话选择按钮。 @@ -185,6 +195,7 @@ class AgentChatMessage(BaseModel): createdAt: Union[int, float] = Field(..., description="创建时间戳") status: str = Field(default="done", description="消息状态") tools: list[AgentChatToolCall] = Field(default_factory=list, description="工具提示列表") + segments: list[AgentChatMessageSegment] = Field(default_factory=list, description="有序展示片段") attachments: list[AgentChatAttachment] = Field(default_factory=list, description="附件列表") choices: list[AgentChatChoiceCard] = Field(default_factory=list, description="选择卡片列表") choice_selection: Optional[AgentChatChoiceSelection] = Field(None, description="用户选择项快照") diff --git a/tests/test_web_agent_stream.py b/tests/test_web_agent_stream.py index fd7e9cf77..9e819a4c4 100644 --- a/tests/test_web_agent_stream.py +++ b/tests/test_web_agent_stream.py @@ -106,7 +106,7 @@ def test_build_web_agent_session_id_reuses_accessible_history(): def test_apply_web_agent_display_event_updates_snapshot(): - """WebAgent SSE 事件应可聚合为服务端展示快照。""" + """WebAgent SSE 事件应按到达顺序聚合为服务端展示快照。""" message = { "id": "assistant-1", "role": "assistant", @@ -114,12 +114,14 @@ def test_apply_web_agent_display_event_updates_snapshot(): "createdAt": 1, "status": "streaming", "tools": [], + "segments": [], "attachments": [], "choices": [], } _apply_web_agent_display_event({"type": "delta", "content": "你好"}, message) _apply_web_agent_display_event({"type": "tool", "message": "查询订阅"}, message) + _apply_web_agent_display_event({"type": "delta", "content": ",查询完成"}, message) _apply_web_agent_display_event( { "type": "attachment", @@ -129,14 +131,48 @@ def test_apply_web_agent_display_event_updates_snapshot(): ) _apply_web_agent_display_event({"type": "done"}, message) - assert message["content"] == "你好" + assert message["content"] == "你好,查询完成" assert message["status"] == "done" assert len(message["tools"]) == 1 assert message["tools"][0]["message"] == "查询订阅" assert message["tools"][0]["status"] == "done" + assert message["segments"] == [ + {"type": "text", "content": "你好"}, + {"type": "tool", "toolIndex": 0}, + {"type": "text", "content": ",查询完成"}, + ] assert message["attachments"] == [{"kind": "file", "url": "message/agent/file/a"}] +def test_agent_chat_display_schema_preserves_ordered_segments(): + """前端回传会话快照时应保留文字和工具的有序片段。""" + payload = schemas.AgentChatDisplaySaveRequest( + messages=[ + { + "id": "assistant-1", + "role": "assistant", + "content": "先检查检查完成", + "createdAt": 1, + "status": "done", + "tools": [ + {"id": "tool-1", "message": "执行检查", "status": "done"} + ], + "segments": [ + {"type": "text", "content": "先检查"}, + {"type": "tool", "toolIndex": 0}, + {"type": "text", "content": "检查完成"}, + ], + } + ] + ) + + assert payload.messages[0].model_dump()["segments"] == [ + {"type": "text", "content": "先检查", "toolIndex": None}, + {"type": "tool", "content": "", "toolIndex": 0}, + {"type": "text", "content": "检查完成", "toolIndex": None}, + ] + + def test_build_web_agent_input_attachments_marks_kinds(): """WebAgent 用户输入附件应转换为可展示的附件记录。""" attachments = _build_web_agent_input_attachments(