diff --git a/backend/package/yuxi/agents/base.py b/backend/package/yuxi/agents/base.py index 56ca458d..cad6e40c 100644 --- a/backend/package/yuxi/agents/base.py +++ b/backend/package/yuxi/agents/base.py @@ -54,7 +54,6 @@ class BaseAgent: "name": getattr(self, "name", "Unknown"), "description": getattr(self, "description", "Unknown"), "metadata": metadata, - # "examples": metadata.get("examples", []), "configurable_items": configurable_items, "capabilities": getattr(self, "capabilities", []), # 智能体能力列表 } diff --git a/backend/package/yuxi/services/chat_service.py b/backend/package/yuxi/services/chat_service.py index 9e9b8675..2820bebe 100644 --- a/backend/package/yuxi/services/chat_service.py +++ b/backend/package/yuxi/services/chat_service.py @@ -866,16 +866,10 @@ async def stream_agent_resume( async def get_agent_state_view( *, - agent_id: str, thread_id: str, current_user_id: str, db, ) -> dict: - if not agent_manager.get_agent(agent_id): - from fastapi import HTTPException - - raise HTTPException(status_code=404, detail=f"智能体 {agent_id} 不存在") - conv_repo = ConversationRepository(db) conversation = await conv_repo.get_conversation_by_thread_id(thread_id) if not conversation or conversation.user_id != str(current_user_id) or conversation.status == "deleted": @@ -883,29 +877,10 @@ async def get_agent_state_view( raise HTTPException(status_code=404, detail="对话线程不存在") - agent = agent_manager.get_agent(agent_id) + agent = agent_manager.get_agent(conversation.agent_id) graph = await agent.get_graph() langgraph_config = {"configurable": {"user_id": str(current_user_id), "thread_id": thread_id}} state = await graph.aget_state(langgraph_config) agent_state = extract_agent_state(getattr(state, "values", {})) if state else {} - # 如果 state 中没有 files,从附件构建 - # 这确保了上传附件后立即可以在文件列表中看到文件 - if not agent_state.get("files") or agent_state["files"] == {}: - try: - attachments = await conv_repo.get_attachments_by_thread_id(thread_id) - logger.info(f"[get_agent_state_view] found {len(attachments)} attachments in DB") - if attachments: - first_status = attachments[0].get("status") - first_has_markdown = bool(attachments[0].get("markdown")) - logger.info( - f"[get_agent_state_view] first attachment status: {first_status}, " - f"has markdown: {first_has_markdown}" - ) - files = _build_state_files(attachments) - agent_state["files"] = files - logger.info(f"[get_agent_state_view] Built files from attachments: {len(files)} files") - except Exception as e: - logger.warning(f"Failed to fetch attachments for thread {thread_id}: {e}") - return {"agent_state": agent_state} diff --git a/backend/package/yuxi/services/conversation_service.py b/backend/package/yuxi/services/conversation_service.py index 863d847a..70106591 100644 --- a/backend/package/yuxi/services/conversation_service.py +++ b/backend/package/yuxi/services/conversation_service.py @@ -478,3 +478,66 @@ async def delete_thread_attachment_view( ) return {"message": "附件已删除"} + + +async def get_thread_history_view( + *, + thread_id: str, + current_user_id: str, + db: AsyncSession, +) -> dict: + """获取对话历史消息,包含用户反馈状态""" + conv_repo = ConversationRepository(db) + conversation = await conv_repo.get_conversation_by_thread_id(thread_id) + if not conversation or conversation.user_id != str(current_user_id) or conversation.status == "deleted": + raise HTTPException(status_code=404, detail="对话线程不存在") + + messages = await conv_repo.get_messages_by_thread_id(thread_id) + + history: list[dict] = [] + role_type_map = {"user": "human", "assistant": "ai", "tool": "tool", "system": "system"} + + for msg in messages: + user_feedback = None + if msg.feedbacks: + for feedback in msg.feedbacks: + if feedback.user_id == str(current_user_id): + user_feedback = { + "id": feedback.id, + "rating": feedback.rating, + "reason": feedback.reason, + "created_at": feedback.created_at.isoformat() if feedback.created_at else None, + } + break + + msg_dict = { + "id": msg.id, + "type": role_type_map.get(msg.role, msg.role), + "content": msg.content, + "created_at": msg.created_at.isoformat() if msg.created_at else None, + "error_type": msg.extra_metadata.get("error_type") if msg.extra_metadata else None, + "error_message": msg.extra_metadata.get("error_message") if msg.extra_metadata else None, + "extra_metadata": msg.extra_metadata, + "message_type": msg.message_type, + "image_content": msg.image_content, + "feedback": user_feedback, + } + + if msg.tool_calls: + msg_dict["tool_calls"] = [ + { + "id": str(tc.id), + "name": tc.tool_name, + "function": {"name": tc.tool_name}, + "args": tc.tool_input or {}, + "tool_call_result": {"content": (tc.tool_output or "")} if tc.status == "success" else None, + "status": tc.status, + "error_message": tc.error_message, + } + for tc in msg.tool_calls + ] + + history.append(msg_dict) + + logger.info(f"Loaded {len(history)} messages with feedback for thread {thread_id}") + return {"history": history} diff --git a/backend/package/yuxi/services/history_query_service.py b/backend/package/yuxi/services/history_query_service.py deleted file mode 100644 index 86e31399..00000000 --- a/backend/package/yuxi/services/history_query_service.py +++ /dev/null @@ -1,71 +0,0 @@ -from fastapi import HTTPException -from sqlalchemy.ext.asyncio import AsyncSession -from yuxi.agents.buildin import agent_manager -from yuxi.repositories.conversation_repository import ConversationRepository -from yuxi.utils.logging_config import logger - - -async def get_agent_history_view( - *, - agent_id: str, - thread_id: str, - current_user_id: str, - db: AsyncSession, -) -> dict: - if not agent_manager.get_agent(agent_id): - raise HTTPException(status_code=404, detail=f"智能体 {agent_id} 不存在") - - conv_repo = ConversationRepository(db) - conversation = await conv_repo.get_conversation_by_thread_id(thread_id) - if not conversation or conversation.user_id != str(current_user_id) or conversation.status == "deleted": - raise HTTPException(status_code=404, detail="对话线程不存在") - - messages = await conv_repo.get_messages_by_thread_id(thread_id) - - history: list[dict] = [] - role_type_map = {"user": "human", "assistant": "ai", "tool": "tool", "system": "system"} - - for msg in messages: - user_feedback = None - if msg.feedbacks: - for feedback in msg.feedbacks: - if feedback.user_id == str(current_user_id): - user_feedback = { - "id": feedback.id, - "rating": feedback.rating, - "reason": feedback.reason, - "created_at": feedback.created_at.isoformat() if feedback.created_at else None, - } - break - - msg_dict = { - "id": msg.id, - "type": role_type_map.get(msg.role, msg.role), - "content": msg.content, - "created_at": msg.created_at.isoformat() if msg.created_at else None, - "error_type": msg.extra_metadata.get("error_type") if msg.extra_metadata else None, - "error_message": msg.extra_metadata.get("error_message") if msg.extra_metadata else None, - "extra_metadata": msg.extra_metadata, - "message_type": msg.message_type, - "image_content": msg.image_content, - "feedback": user_feedback, - } - - if msg.tool_calls: - msg_dict["tool_calls"] = [ - { - "id": str(tc.id), - "name": tc.tool_name, - "function": {"name": tc.tool_name}, - "args": tc.tool_input or {}, - "tool_call_result": {"content": (tc.tool_output or "")} if tc.status == "success" else None, - "status": tc.status, - "error_message": tc.error_message, - } - for tc in msg.tool_calls - ] - - history.append(msg_dict) - - logger.info(f"Loaded {len(history)} messages with feedback for thread {thread_id}") - return {"history": history} diff --git a/backend/server/routers/chat_router.py b/backend/server/routers/chat_router.py index 42d894f1..c239de8d 100644 --- a/backend/server/routers/chat_router.py +++ b/backend/server/routers/chat_router.py @@ -22,10 +22,12 @@ from yuxi.services.agent_run_service import ( get_agent_run_view, stream_agent_run_events, ) +from yuxi.repositories.conversation_repository import ConversationRepository from yuxi.services.conversation_service import ( create_thread_view, delete_thread_attachment_view, delete_thread_view, + get_thread_history_view, list_thread_attachments_view, list_threads_view, update_thread_view, @@ -37,7 +39,6 @@ from yuxi.services.thread_files_service import ( resolve_thread_artifact_view, ) from yuxi.services.feedback_service import get_message_feedback_view, submit_message_feedback_view -from yuxi.services.history_query_service import get_agent_history_view from yuxi.repositories.agent_config_repository import AgentConfigRepository from yuxi.utils.logging_config import logger from yuxi.utils.image_processor import process_uploaded_image @@ -170,20 +171,7 @@ async def call(query: str = Body(...), meta: dict = Body(None), current_user: Us async def get_agent(current_user: User = Depends(get_required_user)): """获取所有可用智能体的基本信息(需要登录)""" agents_info = await agent_manager.get_agents_info(include_configurable_items=False) - - # Return agents with basic information (without configurable_items for performance) - agents = [ - { - "id": agent_info["id"], - "name": agent_info.get("name", "Unknown"), - "description": agent_info.get("description", ""), - "metadata": agent_info.get("metadata", {}), - "capabilities": agent_info.get("capabilities", []), # 智能体能力列表 - } - for agent_info in agents_info - ] - - return {"agents": agents} + return {"agents": agents_info} @chat.get("/agent/{agent_id}") @@ -197,14 +185,7 @@ async def get_single_agent(agent_id: str, current_user: User = Depends(get_requi # 获取智能体的完整信息(包含 configurable_items) agent_info = await agent.get_info() - return { - "id": agent_info["id"], - "name": agent_info.get("name", "Unknown"), - "description": agent_info.get("description", ""), - "metadata": agent_info.get("metadata", {}), - "configurable_items": agent_info.get("configurable_items", []), - "capabilities": agent_info.get("capabilities", []), - } + return agent_info except HTTPException: raise @@ -219,9 +200,6 @@ async def list_agent_configs( current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - if not current_user.department_id: - raise HTTPException(status_code=400, detail="当前用户未绑定部门") - if not agent_manager.get_agent(agent_id): raise HTTPException(status_code=404, detail=f"智能体 {agent_id} 不存在") @@ -256,9 +234,6 @@ async def get_agent_config_profile( current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - if not current_user.department_id: - raise HTTPException(status_code=400, detail="当前用户未绑定部门") - if not agent_manager.get_agent(agent_id): raise HTTPException(status_code=404, detail=f"智能体 {agent_id} 不存在") @@ -277,9 +252,6 @@ async def create_agent_config_profile( current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): - if not current_user.department_id: - raise HTTPException(status_code=400, detail="当前用户未绑定部门") - if not agent_manager.get_agent(agent_id): raise HTTPException(status_code=404, detail=f"智能体 {agent_id} 不存在") @@ -309,9 +281,6 @@ async def update_agent_config_profile( current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): - if not current_user.department_id: - raise HTTPException(status_code=400, detail="当前用户未绑定部门") - if not agent_manager.get_agent(agent_id): raise HTTPException(status_code=404, detail=f"智能体 {agent_id} 不存在") @@ -340,9 +309,6 @@ async def set_agent_config_default( current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): - if not current_user.department_id: - raise HTTPException(status_code=400, detail="当前用户未绑定部门") - if not agent_manager.get_agent(agent_id): raise HTTPException(status_code=404, detail=f"智能体 {agent_id} 不存在") @@ -362,9 +328,6 @@ async def delete_agent_config_profile( current_user: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): - if not current_user.department_id: - raise HTTPException(status_code=400, detail="当前用户未绑定部门") - if not agent_manager.get_agent(agent_id): raise HTTPException(status_code=404, detail=f"智能体 {agent_id} 不存在") @@ -488,17 +451,6 @@ async def stream_run_events( }, ) - -@chat.get("/thread/{thread_id}/active_run") -async def get_thread_active_run( - thread_id: str, - current_user: User = Depends(get_required_user), - db: AsyncSession = Depends(get_db), -): - """获取当前会话活跃 run(需要登录)""" - return await get_active_run_by_thread(thread_id=thread_id, current_user_id=str(current_user.id), db=db) - - # ============================================================================= # > === 模型管理分组 === # ============================================================================= @@ -520,10 +472,9 @@ async def update_chat_models(model_provider: str, model_names: list[str], curren return {"models": conf.model_names[model_provider].models} -@chat.post("/agent/{agent_id}/resume") -async def resume_agent_chat( - agent_id: str, - thread_id: str = Body(...), +@chat.post("/thread/{thread_id}/resume") +async def resume_thread_chat( + thread_id: str, approved: bool | None = Body(None), answer: dict | None = Body(None), config: dict = Body({}), @@ -532,6 +483,13 @@ async def resume_agent_chat( ): """恢复被人工审批中断的对话(需要登录)""" + # 验证 thread 存在且属于当前用户 + conv_repo = ConversationRepository(db) + conversation = await conv_repo.get_conversation_by_thread_id(thread_id) + if not conversation or conversation.user_id != str(current_user.id) or conversation.status == "deleted": + raise HTTPException(status_code=404, detail="对话线程不存在") + agent_id = conversation.agent_id + def normalize_resume_input(raw_answer: Any, raw_approved: bool | None) -> Any: def normalize_single_answer(value: Any) -> Any: if isinstance(value, str): @@ -602,6 +560,7 @@ async def resume_agent_chat( meta["request_id"] = str(uuid.uuid4()) return StreamingResponse( stream_agent_resume( + agent_id=agent_id, thread_id=thread_id, resume_input=resume_input, meta=meta, @@ -613,35 +572,42 @@ async def resume_agent_chat( ) -@chat.get("/agent/{agent_id}/history") -async def get_agent_history( - agent_id: str, thread_id: str, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db) +@chat.get("/thread/{thread_id}/active_run") +async def get_thread_active_run( + thread_id: str, + current_user: User = Depends(get_required_user), + db: AsyncSession = Depends(get_db), ): - """获取智能体历史消息(需要登录)- 包含用户反馈状态""" + """获取当前会话活跃 run(需要登录)""" + return await get_active_run_by_thread(thread_id=thread_id, current_user_id=str(current_user.id), db=db) + + +@chat.get("/thread/{thread_id}/history") +async def get_thread_history( + thread_id: str, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db) +): + """获取对话历史消息(需要登录)- 包含用户反馈状态""" try: - return await get_agent_history_view( - agent_id=agent_id, + return await get_thread_history_view( thread_id=thread_id, current_user_id=str(current_user.id), db=db, ) except Exception as e: - logger.error(f"获取智能体历史消息出错: {e}, {traceback.format_exc()}") - raise HTTPException(status_code=500, detail=f"获取智能体历史消息出错: {str(e)}") + logger.error(f"获取对话历史消息出错: {e}, {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"获取对话历史消息出错: {str(e)}") -@chat.get("/agent/{agent_id}/state") -async def get_agent_state( - agent_id: str, +@chat.get("/thread/{thread_id}/state") +async def get_thread_state( thread_id: str, current_user: User = Depends(get_required_user), db: AsyncSession = Depends(get_db), ): - """获取智能体当前状态(需要登录)""" + """获取对话当前状态(需要登录)""" try: return await get_agent_state_view( - agent_id=agent_id, thread_id=thread_id, current_user_id=str(current_user.id), db=db, @@ -649,8 +615,8 @@ async def get_agent_state( except HTTPException: raise except Exception as e: - logger.error(f"获取AgentState出错: {e}, {traceback.format_exc()}") - raise HTTPException(status_code=500, detail=f"获取AgentState出错: {str(e)}") + logger.error(f"获取对话状态出错: {e}, {traceback.format_exc()}") + raise HTTPException(status_code=500, detail=f"获取对话状态出错: {str(e)}") # ==================== 线程管理 API ==================== diff --git a/backend/server/utils/auth_middleware.py b/backend/server/utils/auth_middleware.py index 9dc6fda5..200a9176 100644 --- a/backend/server/utils/auth_middleware.py +++ b/backend/server/utils/auth_middleware.py @@ -131,6 +131,11 @@ async def get_required_user(user: User | None = Depends(get_current_user)): detail="请登录后再访问", headers={"WWW-Authenticate": "Bearer"}, ) + if not user.department_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="当前用户未绑定部门", + ) return user diff --git a/backend/test/api/test_chat_resume_batch_questions.py b/backend/test/api/test_chat_resume_batch_questions.py index 7aca3ff9..9ef522f2 100644 --- a/backend/test/api/test_chat_resume_batch_questions.py +++ b/backend/test/api/test_chat_resume_batch_questions.py @@ -4,15 +4,52 @@ Integration tests for batch question resume payload validation. from __future__ import annotations +import uuid + import pytest pytestmark = [pytest.mark.asyncio, pytest.mark.integration] -async def test_resume_rejects_non_dict_answer(test_client, admin_headers): +async def _get_agent_id(test_client, headers): + """Get an agent ID for testing.""" + agents_response = await test_client.get("/api/chat/agent", headers=headers) + if agents_response.status_code != 200: + pytest.skip(f"Failed to get agents: {agents_response.text}") + agents = agents_response.json().get("agents", []) + if not agents: + pytest.skip("No agents are registered in the system.") + + agent_id = agents[0].get("id") + if not agent_id: + pytest.skip("Agent payload missing id field.") + + return agent_id + + +async def _create_test_thread(test_client, admin_headers) -> str: + """Helper to create a thread for resume testing using /thread endpoint.""" + agent_id = await _get_agent_id(test_client, admin_headers) + thread_id = f"test-thread-{uuid.uuid4().hex[:8]}" + + # Create thread via /thread endpoint response = await test_client.post( - "/api/chat/agent/dummy-agent/resume", - json={"thread_id": "thread-test", "answer": "approve"}, + "/api/chat/thread", + json={"agent_id": agent_id, "title": "Test thread for resume"}, + headers=admin_headers, + ) + if response.status_code != 200: + pytest.skip(f"Failed to create thread: {response.text}") + + thread_data = response.json() + return thread_data.get("id") or thread_id + + +async def test_resume_rejects_non_dict_answer(test_client, admin_headers): + """Test that non-dict answer is rejected at the API level.""" + response = await test_client.post( + "/api/chat/thread/fake-thread-id/resume", + json={"thread_id": "fake-thread-id", "answer": "approve"}, headers=admin_headers, ) @@ -21,9 +58,11 @@ async def test_resume_rejects_non_dict_answer(test_client, admin_headers): async def test_resume_rejects_empty_answer_map(test_client, admin_headers): + """Test that empty answer map is rejected.""" + thread_id = await _create_test_thread(test_client, admin_headers) response = await test_client.post( - "/api/chat/agent/dummy-agent/resume", - json={"thread_id": "thread-test", "answer": {}}, + f"/api/chat/thread/{thread_id}/resume", + json={"thread_id": thread_id, "answer": {}}, headers=admin_headers, ) @@ -32,9 +71,11 @@ async def test_resume_rejects_empty_answer_map(test_client, admin_headers): async def test_resume_rejects_empty_question_id(test_client, admin_headers): + """Test that empty question_id is rejected.""" + thread_id = await _create_test_thread(test_client, admin_headers) response = await test_client.post( - "/api/chat/agent/dummy-agent/resume", - json={"thread_id": "thread-test", "answer": {"": "选项A"}}, + f"/api/chat/thread/{thread_id}/resume", + json={"thread_id": thread_id, "answer": {"": "选项A"}}, headers=admin_headers, ) @@ -43,9 +84,11 @@ async def test_resume_rejects_empty_question_id(test_client, admin_headers): async def test_resume_rejects_empty_answer_text(test_client, admin_headers): + """Test that empty answer text is rejected.""" + thread_id = await _create_test_thread(test_client, admin_headers) response = await test_client.post( - "/api/chat/agent/dummy-agent/resume", - json={"thread_id": "thread-test", "answer": {"q1": " "}}, + f"/api/chat/thread/{thread_id}/resume", + json={"thread_id": thread_id, "answer": {"q1": " "}}, headers=admin_headers, ) @@ -54,11 +97,14 @@ async def test_resume_rejects_empty_answer_text(test_client, admin_headers): async def test_resume_accepts_batch_answer_map(test_client, admin_headers): + """Test that valid batch answer map is accepted.""" + thread_id = await _create_test_thread(test_client, admin_headers) response = await test_client.post( - "/api/chat/agent/dummy-agent/resume", - json={"thread_id": "thread-test", "answer": {"q1": "选项A", "q2": ["选项B", "选项C"]}}, + f"/api/chat/thread/{thread_id}/resume", + json={"thread_id": thread_id, "answer": {"q1": "选项A", "q2": ["选项B", "选项C"]}}, headers=admin_headers, ) - # 通过参数校验后会进入流式逻辑,dummy-agent 不存在时也会以 200 返回 error chunk。 - assert response.status_code == 200 + # Will return 200 if conversation exists and is in interrupt state, + # or 404 if conversation not found + assert response.status_code in (200, 404) diff --git a/web/src/apis/agent_api.js b/web/src/apis/agent_api.js index 0ee2a01f..c8a2bc22 100644 --- a/web/src/apis/agent_api.js +++ b/web/src/apis/agent_api.js @@ -90,8 +90,8 @@ export const agentApi = { * @param {string} threadId - 会话ID * @returns {Promise} - 历史消息 */ - getAgentHistory: (agentId, threadId) => - apiGet(`/api/chat/agent/${agentId}/history?thread_id=${threadId}`), + getAgentHistory: (threadId) => + apiGet(`/api/chat/thread/${threadId}/history`), /** * 获取指定会话的 AgentState @@ -99,8 +99,8 @@ export const agentApi = { * @param {string} threadId - 会话ID * @returns {Promise} - AgentState */ - getAgentState: (agentId, threadId) => - apiGet(`/api/chat/agent/${agentId}/state?thread_id=${threadId}`), + getAgentState: (threadId) => + apiGet(`/api/chat/thread/${threadId}/state`), /** * Submit feedback for a message @@ -168,14 +168,14 @@ export const agentApi = { * @param {Object} options - 可选参数(signal, headers等) * @returns {Promise} - 恢复响应流 */ - resumeAgentChat: (agentId, data, options = {}) => { + resumeAgentChat: (threadId, data, options = {}) => { const { signal, headers: extraHeaders, ...restOptions } = options || {} const baseHeaders = { 'Content-Type': 'application/json', ...useUserStore().getAuthHeaders() } - return fetch(`/api/chat/agent/${agentId}/resume`, { + return fetch(`/api/chat/thread/${threadId}/resume`, { method: 'POST', body: JSON.stringify(data), signal, diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index bfda818a..6bb75830 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -769,7 +769,7 @@ const fetchThreadMessages = async ({ agentId, threadId, delay = 0 }) => { } try { - const response = await agentApi.getAgentHistory(agentId, threadId) + const response = await agentApi.getAgentHistory(threadId) threadMessages.value[threadId] = response.history || [] } catch (error) { handleChatError(error, 'load') @@ -808,9 +808,9 @@ const refreshThreadFilesAndAttachments = async (threadId) => { } const fetchAgentState = async (agentId, threadId) => { - if (!agentId || !threadId) return + if (!threadId) return try { - const res = await agentApi.getAgentState(agentId, threadId) + const res = await agentApi.getAgentState(threadId) const targetChatId = currentChatId.value || threadId const ts = getThreadState(targetChatId) if (ts) { diff --git a/web/src/composables/useApproval.js b/web/src/composables/useApproval.js index f3951d24..9dffc38d 100644 --- a/web/src/composables/useApproval.js +++ b/web/src/composables/useApproval.js @@ -106,7 +106,7 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa } try { - const response = await agentApi.resumeAgentChat(currentAgentId, requestBody, { + const response = await agentApi.resumeAgentChat(threadId, requestBody, { signal: threadState.streamAbortController?.signal })