diff --git a/backend/app/agent/factory/browser.py b/backend/app/agent/factory/browser.py index 161c809b..f9cd387e 100644 --- a/backend/app/agent/factory/browser.py +++ b/backend/app/agent/factory/browser.py @@ -189,6 +189,25 @@ def browser_agent(options: Chat): else: selected_port = env("browser_port", "9222") + enabled_browser_tools = [ + "browser_click", + "browser_type", + "browser_back", + "browser_forward", + "browser_select", + "browser_console_exec", + "browser_console_view", + "browser_switch_tab", + "browser_enter", + "browser_visit_page", + "browser_scroll", + "browser_sheet_read", + "browser_sheet_input", + "browser_get_page_snapshot", + ] + if selected_is_external: + enabled_browser_tools.append("browser_open") + web_toolkit_custom = HybridBrowserToolkit( options.project_id, cdp_keep_current_page=True, @@ -197,23 +216,7 @@ def browser_agent(options: Chat): stealth=True, session_id=toolkit_session_id, cdp_url=f"http://localhost:{selected_port}", - enabled_tools=[ - "browser_click", - "browser_type", - "browser_back", - "browser_forward", - "browser_select", - "browser_console_exec", - "browser_console_view", - "browser_switch_tab", - "browser_enter", - "browser_visit_page", - "browser_scroll", - "browser_sheet_read", - "browser_sheet_input", - "browser_get_page_snapshot", - "browser_open", - ], + enabled_tools=enabled_browser_tools, ) # Save reference before registering for toolkits_to_register_agent diff --git a/backend/app/controller/chat_controller.py b/backend/app/controller/chat_controller.py index 87e42a70..c487347a 100644 --- a/backend/app/controller/chat_controller.py +++ b/backend/app/controller/chat_controller.py @@ -342,6 +342,7 @@ def improve(id: str, data: SupplementChat): data=ImprovePayload( question=data.question, attaches=data.attaches or [], + target=data.target, ), new_task_id=data.task_id, ) @@ -349,7 +350,7 @@ def improve(id: str, data: SupplementChat): ) chat_logger.info( "Improvement request queued with preserved context", - extra={"project_id": id}, + extra={"project_id": id, "target": data.target}, ) return Response(status_code=201) diff --git a/backend/app/model/chat.py b/backend/app/model/chat.py index 8f13a5aa..4a7c60d1 100644 --- a/backend/app/model/chat.py +++ b/backend/app/model/chat.py @@ -78,6 +78,9 @@ class Chat(BaseModel): search_config: dict[str, str] | None = None # User identifier for user-specific skill configurations user_id: str | None = None + # Target agent for @mention routing: "browser", "dev", "doc", + # "media", "workforce", or None (default behavior) + target: str | None = None @field_validator("model_type") @classmethod @@ -141,6 +144,7 @@ class SupplementChat(BaseModel): question: str task_id: str | None = None attaches: list[str] = [] + target: str | None = None class HumanReply(BaseModel): diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index 41c912ab..a8b07e83 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -35,6 +35,7 @@ from app.agent.factory import ( mcp_agent, multi_modal_agent, question_confirm_agent, + social_media_agent, task_summary_agent, ) from app.agent.listen_chat_agent import ListenChatAgent @@ -48,6 +49,7 @@ from app.service.task import ( Action, ActionDecomposeProgressData, ActionDecomposeTextData, + ActionEndData, ActionImproveData, ActionInstallMcpData, ActionNewAgent, @@ -284,6 +286,98 @@ def build_context_for_workforce( ) +# ================================================================ +# @mention direct agent routing +# ================================================================ + +# Maps @mention target names to (factory_fn, is_async) pairs +_AGENT_TARGET_MAP: dict[str, tuple] = { + "browser": (browser_agent, False), + "dev": (developer_agent, True), + "doc": (document_agent, True), + "media": (multi_modal_agent, False), + "social": (social_media_agent, True), +} + + +async def _create_persistent_agent( + target: str, options: Chat +) -> ListenChatAgent: + """Create a persistent agent by target name using existing factories.""" + if target not in _AGENT_TARGET_MAP: + raise ValueError( + f"Unknown agent target: {target}. " + f"Valid targets: {list(_AGENT_TARGET_MAP.keys())}" + ) + factory_fn, is_async = _AGENT_TARGET_MAP[target] + if is_async: + agent = await factory_fn(options) + else: + agent = await asyncio.to_thread(factory_fn, options) + logger.info( + f"[DIRECT-AGENT] Created persistent agent: {target}", + extra={ + "project_id": options.project_id, + "agent_name": getattr(agent, "agent_name", target), + "agent_id": getattr(agent, "agent_id", ""), + }, + ) + return agent + + +async def _run_direct_agent( + agent, + prompt: str, + question: str, + task_lock: TaskLock, +): + """Background task that runs a direct agent step. + + agent.astep() internally sends activate_agent and deactivate_agent + events via the queue, so step_solve's main loop can process them + in real-time alongside toolkit events. + + After completion, puts ActionEndData into the queue so step_solve + yields the 'end' SSE event. + """ + from camel.agents.chat_agent import ( + AsyncStreamingChatAgentResponse, + ) + + response_content = "" + try: + response = await agent.astep(prompt) + if isinstance(response, AsyncStreamingChatAgentResponse): + # Must consume the stream to trigger deactivation + # in _astream_chunks's finally block + async for chunk in response: + if chunk.msg and chunk.msg.content: + response_content += chunk.msg.content + else: + response_content = response.msg.content if response.msg else "" + except Exception as e: + logger.error( + f"[DIRECT-AGENT] Error executing agent: {e}", + exc_info=True, + ) + response_content = f"Error executing agent: {e}" + + # Save conversation history + task_lock.add_conversation("user", question) + task_lock.add_conversation("assistant", response_content) + + # Yield control so the agent's deactivate_agent event + # (scheduled via _schedule_async_task in _astream_chunks's + # finally block) fires before the end event. + # Without this, end arrives first and frontend ignores + # deactivate_agent because task is already FINISHED. + await asyncio.sleep(0.1) + + # Signal completion — step_solve's Action.end handler + # will yield sse_json("end", ...) and set status to done + await task_lock.put_queue(ActionEndData(data=response_content[:300])) + + @sync_step async def step_solve(options: Chat, request: Request, task_lock: TaskLock): """Main task execution loop. Called when POST /chat endpoint @@ -469,6 +563,135 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock): f"'{question[:100]}...'" ) + # --- @mention direct agent routing --- + target: str | None = None + if ( + hasattr(options, "target") + and options.target + and loop_iteration == 1 + ): + target = options.target + elif isinstance(item, ActionImproveData) and item.data.target: + target = item.data.target + + if ( + target + and target != "workforce" + and target in _AGENT_TARGET_MAP + ): + # Direct agent mode: keep the SAME task_id + # across turns (continuous chat in one chatStore). + # Do NOT update options.task_id here — the + # frontend reuses the existing chatStore. + + logger.info( + f"[DIRECT-AGENT] Routing to @{target}", + extra={ + "project_id": options.project_id, + "target": target, + "task_id": options.task_id, + }, + ) + + # Send confirmed event so frontend transitions + # from pending/splitting state. + # direct=True tells frontend to skip task + # splitting and go straight to RUNNING. + yield sse_json( + "confirmed", + {"question": question, "direct": True}, + ) + + # Ensure event loop is set for agent internals + set_main_event_loop(asyncio.get_running_loop()) + + # Get or create persistent agent + agent = task_lock.persistent_agents.get(target) + is_new_agent = agent is None + + logger.info( + f"[DIRECT-AGENT] persistent_agents" + f" keys: {list(task_lock.persistent_agents.keys())}," + f" is_new={is_new_agent}," + f" target={target}", + ) + + if is_new_agent: + # Factory internally sends create_agent + # via ActionCreateAgentData in the queue + agent = await _create_persistent_agent(target, options) + task_lock.persistent_agents[target] = agent + logger.info( + f"[DIRECT-AGENT] Created NEW " + f"agent: {agent.agent_name} " + f"(id={agent.agent_id})", + ) + else: + logger.info( + f"[DIRECT-AGENT] REUSING " + f"agent: {agent.agent_name} " + f"(id={agent.agent_id})", + ) + # Reused agent: factory won't send + # create_agent, so we must send it + # explicitly for the new chatStore + tool_names = [] + for t in getattr(agent, "tools", []): + fn_name = getattr(t, "get_function_name", None) + if fn_name: + tool_names.append(fn_name()) + yield sse_json( + "create_agent", + { + "agent_name": agent.agent_name, + "agent_id": agent.agent_id, + "tools": tool_names, + }, + ) + + agent.process_task_id = options.task_id + + # Build prompt: reused agents already have + # conversation history in their CAMEL memory, + # so only prepend context for brand-new agents. + if is_new_agent: + conv_ctx = build_conversation_context( + task_lock, + header="=== Previous Conversation ===", + ) + prompt = f"{conv_ctx}\nUser: {question}" + else: + prompt = question + if attaches_to_use: + prompt += f"\n\nAttached files: {attaches_to_use}" + + # Launch agent in background task so step_solve's + # while loop can process queue events (activate, + # toolkit, deactivate) in real-time. + # agent.astep() internally sends activate_agent + # and deactivate_agent via the queue. + task = asyncio.create_task( + _run_direct_agent( + agent, + prompt, + question, + task_lock, + ) + ) + task_lock.add_background_task(task) + continue + + if target == "workforce": + logger.info( + "[DIRECT-AGENT] @workforce: " + "cleaning up persistent agents", + extra={ + "project_id": options.project_id, + }, + ) + await task_lock.cleanup_persistent_agents() + # --- end @mention routing --- + is_exceeded, total_length = check_conversation_history_length( task_lock ) diff --git a/backend/app/service/task.py b/backend/app/service/task.py index 604fbc71..05f662a4 100644 --- a/backend/app/service/task.py +++ b/backend/app/service/task.py @@ -76,6 +76,7 @@ class ImprovePayload(BaseModel): question: str attaches: list[str] = [] + target: str | None = None class ActionImproveData(BaseModel): @@ -225,6 +226,7 @@ class ActionStopData(BaseModel): class ActionEndData(BaseModel): action: Literal[Action.end] = Action.end + data: str | None = None class ActionTimeoutData(BaseModel): @@ -351,6 +353,8 @@ class TaskLock: """Track if summary has been generated for this project""" current_task_id: str | None """Current task ID to be used in SSE responses""" + persistent_agents: dict[str, Any] + """Persistent agents for @mention direct chat (key: agent type name)""" def __init__( self, id: str, queue: asyncio.Queue, human_input: dict @@ -369,6 +373,7 @@ class TaskLock: self.last_task_summary = "" self.question_agent = None self.current_task_id = None + self.persistent_agents = {} logger.info( "Task lock initialized", @@ -426,6 +431,35 @@ class TaskLock: self.background_tasks.add(task) task.add_done_callback(lambda t: self.background_tasks.discard(t)) + async def cleanup_persistent_agents(self): + r"""Release all persistent agents and their resources (e.g. CDP).""" + if not self.persistent_agents: + return + logger.info( + "Cleaning up persistent agents", + extra={ + "task_id": self.id, + "agents": list(self.persistent_agents.keys()), + }, + ) + for name, agent in self.persistent_agents.items(): + try: + if ( + hasattr(agent, "_cdp_release_callback") + and agent._cdp_release_callback + ): + agent._cdp_release_callback(agent) + logger.info( + f"Released CDP for persistent agent {name}", + extra={"task_id": self.id}, + ) + except Exception as e: + logger.warning( + f"Failed to release CDP for persistent agent {name}: {e}", + extra={"task_id": self.id}, + ) + self.persistent_agents.clear() + async def cleanup(self): r"""Cancel all background tasks and clean up resources""" logger.info( @@ -435,6 +469,10 @@ class TaskLock: "background_tasks_count": len(self.background_tasks), }, ) + + # Clean up persistent agents first + await self.cleanup_persistent_agents() + for task in list(self.background_tasks): if not task.done(): task.cancel() diff --git a/electron/main/index.ts b/electron/main/index.ts index 8277c255..ce1aa886 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -352,6 +352,9 @@ app.commandLine.appendSwitch('max_old_space_size', '4096'); app.commandLine.appendSwitch('enable-features', 'MemoryPressureReduction'); app.commandLine.appendSwitch('renderer-process-limit', '8'); +// Disable Fontations (Rust-based font engine) to prevent crashes on macOS +app.commandLine.appendSwitch('disable-features', 'Fontations'); + // ==================== Proxy configuration ==================== // Read proxy from global .env file on startup proxyUrl = readGlobalEnvKey('HTTP_PROXY'); diff --git a/server/uv.lock b/server/uv.lock index b5b91ffd..a5fd7dea 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = "==3.12.*" resolution-markers = [ "sys_platform == 'win32'", @@ -223,6 +223,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/2e/500ff29726ef207fdf6b625e62caf3839662c5d845897efc93bdf019192a/convert_case-1.2.3-py3-none-any.whl", hash = "sha256:ec8884050ca548e990666f82cba7ae2edfaa3c85dbead3042c2fd663b292373a", size = 9373, upload-time = "2023-05-23T19:27:06.039Z" }, ] +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, + { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, + { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, + { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, + { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, + { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, + { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + [[package]] name = "cryptography" version = "46.0.3" @@ -327,6 +351,13 @@ dependencies = [ { name = "sqlmodel" }, ] +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.15.2" }, @@ -352,6 +383,9 @@ requires-dist = [ { name = "pydantic-i18n", specifier = ">=0.4.5" }, { name = "pydash", specifier = ">=8.0.5" }, { name = "pyjwt", specifier = ">=2.10.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "python-multipart", specifier = ">=0.0.20" }, { name = "requests", specifier = ">=2.32.4" }, @@ -359,6 +393,7 @@ requires-dist = [ { name = "sqlalchemy-utils", specifier = ">=0.41.2" }, { name = "sqlmodel", specifier = ">=0.0.24" }, ] +provides-extras = ["dev"] [[package]] name = "email-validator" @@ -470,6 +505,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, @@ -531,6 +567,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "itsdangerous" version = "2.2.0" @@ -698,6 +743,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, ] +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + [[package]] name = "pandas" version = "3.0.0" @@ -752,6 +806,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "psutil" version = "5.9.8" @@ -881,6 +944,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a5/b7/cc5e7974699db40014d58c7dd7c4ad4ffc244d36930dc9ec7d06ee67d7a9/pydash-8.0.6-py3-none-any.whl", hash = "sha256:ee70a81a5b292c007f28f03a4ee8e75c1f5d7576df5457b836ec7ab2839cc5d0", size = 101561, upload-time = "2026-01-17T16:42:55.448Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pyjwt" version = "2.10.1" @@ -895,6 +967,49 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" diff --git a/src/components/ChatBox/BottomBox/InputBox.tsx b/src/components/ChatBox/BottomBox/InputBox.tsx index 4ffab517..ab26b001 100644 --- a/src/components/ChatBox/BottomBox/InputBox.tsx +++ b/src/components/ChatBox/BottomBox/InputBox.tsx @@ -29,9 +29,14 @@ import { UploadCloud, X, } from 'lucide-react'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; +import { + BUILTIN_AGENTS, + MentionAgent, + MentionDropdown, +} from './MentionDropdown'; /** * File attachment object @@ -71,6 +76,10 @@ export interface InputboxProps { privacy?: boolean; /** Use cloud model in dev */ useCloudModelInDev?: boolean; + /** Active @mention target (e.g. "browser") — shown as a tag in the input */ + mentionTarget?: string | null; + /** Callback when mention target changes */ + onMentionTargetChange?: (target: string | null) => void; } /** @@ -126,6 +135,8 @@ export const Inputbox = ({ allowDragDrop = false, privacy = true, useCloudModelInDev = false, + mentionTarget, + onMentionTargetChange, }: InputboxProps) => { const { t } = useTranslation(); const internalTextareaRef = useRef(null); @@ -137,6 +148,11 @@ export const Inputbox = ({ const [isRemainingOpen, setIsRemainingOpen] = useState(false); const hoverCloseTimerRef = useRef(null); const [isComposing, setIsComposing] = useState(false); + const [mentionState, setMentionState] = useState<{ + visible: boolean; + filter: string; + startIndex: number; + }>({ visible: false, filter: '', startIndex: -1 }); const openRemainingPopover = () => { if (hoverCloseTimerRef.current) { @@ -168,8 +184,56 @@ export const Inputbox = ({ const hasContent = value.trim().length > 0 || files.length > 0; const isActive = isFocused || hasContent; - const handleTextChange = (newValue: string) => { + const handleTextChange = useCallback( + (newValue: string, cursorPos?: number) => { + onChange?.(newValue); + + // Detect @ mention + const pos = cursorPos ?? newValue.length; + const textBeforeCursor = newValue.slice(0, pos); + const lastAtIndex = textBeforeCursor.lastIndexOf('@'); + + if ( + lastAtIndex >= 0 && + (lastAtIndex === 0 || + textBeforeCursor[lastAtIndex - 1] === ' ' || + textBeforeCursor[lastAtIndex - 1] === '\n') + ) { + const filterText = textBeforeCursor.slice(lastAtIndex + 1); + if (!filterText.includes(' ')) { + setMentionState({ + visible: true, + filter: filterText, + startIndex: lastAtIndex, + }); + return; + } + } + setMentionState({ visible: false, filter: '', startIndex: -1 }); + }, + [onChange] + ); + + const handleMentionSelect = (agent: MentionAgent) => { + // Remove the "@filter" text from the input and set the + // mention target as a rendered tag instead + const currentValue = value; + const before = currentValue.slice(0, mentionState.startIndex); + const afterFilterEnd = + mentionState.startIndex + 1 + mentionState.filter.length; + const after = currentValue.slice(afterFilterEnd); + const newValue = `${before}${after}`.trimStart(); onChange?.(newValue); + onMentionTargetChange?.(agent.id); + setMentionState({ visible: false, filter: '', startIndex: -1 }); + + // Focus textarea + requestAnimationFrame(() => { + const el = textareaRef.current; + if (el) { + el.focus(); + } + }); }; const handleSend = () => { @@ -183,6 +247,23 @@ export const Inputbox = ({ }; const handleKeyDown = (e: React.KeyboardEvent) => { + // When mention dropdown is open, let it handle navigation keys + if (mentionState.visible) { + if (['ArrowUp', 'ArrowDown', 'Tab', 'Escape', 'Enter'].includes(e.key)) { + e.preventDefault(); // Stop textarea from scrolling / inserting newline + return; // Let MentionDropdown's global handler handle these + } + } + // Backspace at cursor position 0 removes the mention tag + if ( + e.key === 'Backspace' && + mentionTarget && + textareaRef.current?.selectionStart === 0 && + textareaRef.current?.selectionEnd === 0 + ) { + e.preventDefault(); + onMentionTargetChange?.(null); + } if (e.key === 'Enter' && !e.shiftKey && !disabled && !isComposing) { e.preventDefault(); handleSend(); @@ -289,15 +370,43 @@ export const Inputbox = ({
Drop files to attach
)} + {/* @Mention Dropdown */} + + setMentionState({ visible: false, filter: '', startIndex: -1 }) + } + /> + {/* Text Input Area */}
-
+
+ {/* @Mention Tag */} + {mentionTarget && ( + onMentionTargetChange?.(null)} + title="Click to remove" + > + @ + {BUILTIN_AGENTS.find((a) => a.id === mentionTarget)?.label ?? + mentionTarget} + + + )}