From 26a00eb2f9a83ed0e95a9d07ef920e82b7753602 Mon Sep 17 00:00:00 2001 From: puzhen <1303385763@qq.com> Date: Fri, 27 Feb 2026 01:03:48 +0000 Subject: [PATCH 01/22] feat: add @mention routing for direct single-agent conversation - Add @mention dropdown UI in input box (keyboard navigable) - Parse {{@agentId}} tags in message content for mention badge rendering - Backend: route @agent messages directly to persistent agents, skip workforce - Backend: reuse persistent agents across turns (preserve toolkit state) - Frontend: persist mention target across turns, render in input and chat bubbles - Fix keyboard ArrowUp/Down selection in mention dropdown --- backend/app/agent/factory/browser.py | 37 +-- backend/app/controller/chat_controller.py | 3 +- backend/app/model/chat.py | 4 + backend/app/service/chat_service.py | 223 ++++++++++++++++++ backend/app/service/task.py | 38 +++ electron/main/index.ts | 3 + server/uv.lock | 117 ++++++++- src/components/ChatBox/BottomBox/InputBox.tsx | 117 ++++++++- .../ChatBox/BottomBox/MentionDropdown.tsx | 156 ++++++++++++ .../ChatBox/MessageItem/UserMessageCard.tsx | 61 +++-- src/components/ChatBox/index.tsx | 55 ++++- src/store/chatStore.ts | 80 ++++++- src/types/chatbox.d.ts | 1 + 13 files changed, 843 insertions(+), 52 deletions(-) create mode 100644 src/components/ChatBox/BottomBox/MentionDropdown.tsx diff --git a/backend/app/agent/factory/browser.py b/backend/app/agent/factory/browser.py index 161c809b4..f9cd387e3 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 87e42a70a..c487347ac 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 8f13a5aa4..4a7c60d16 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 41c912ab6..a8b07e83d 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -35,6 +35,7 @@ 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 @@ 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 604fbc717..05f662a4f 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 @@ def __init__( 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 @@ def add_background_task(self, task: asyncio.Task) -> None: 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 @@ async def cleanup(self): "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 8277c2551..ce1aa8866 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 b5b91ffd0..a5fd7dea8 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 4ffab5177..ab26b001c 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} + + + )} + +
+
+ + + Not connected + +
+
+
+ + + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 000000000..7b24b5d07 --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,386 @@ +// State +let isConnected = false; +let currentTabId = null; +let currentTabUrl = ''; +let _conversationHistory = []; + +// DOM Elements +const settingsPage = document.getElementById('settingsPage'); +const chatPage = document.getElementById('chatPage'); +const settingsBtn = document.getElementById('settingsBtn'); +const backToChat = document.getElementById('backToChat'); +const connectBtn = document.getElementById('connectBtn'); +const connectionDot = document.getElementById('connectionDot'); +const connectionText = document.getElementById('connectionText'); +const serverUrlInput = document.getElementById('serverUrl'); +const currentPageUrl = document.getElementById('currentPageUrl'); +const messagesContainer = document.getElementById('messagesContainer'); +const messageInput = document.getElementById('messageInput'); +const sendBtn = document.getElementById('sendBtn'); +const statusHint = document.getElementById('statusHint'); + +// Initialize +document.addEventListener('DOMContentLoaded', async () => { + // Get current tab info + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab) { + currentTabId = tab.id; + currentTabUrl = tab.url; + currentPageUrl.textContent = truncateUrl(tab.url, 50); + currentPageUrl.title = tab.url; + } + + // Check connection status + chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (response) => { + if (response && response.connected) { + updateConnectionStatus(true); + } + }); + + // Listen for messages from background + chrome.runtime.onMessage.addListener(handleBackgroundMessage); + + // Setup event listeners + setupEventListeners(); +}); + +function setupEventListeners() { + // Settings toggle + settingsBtn.addEventListener('click', () => { + settingsPage.classList.remove('hidden'); + chatPage.classList.add('hidden'); + }); + + backToChat.addEventListener('click', () => { + chatPage.classList.remove('hidden'); + settingsPage.classList.add('hidden'); + }); + + // Connect button + connectBtn.addEventListener('click', handleConnect); + + // Send message + sendBtn.addEventListener('click', sendMessage); + messageInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + + // Auto-resize textarea + messageInput.addEventListener('input', () => { + messageInput.style.height = 'auto'; + messageInput.style.height = Math.min(messageInput.scrollHeight, 100) + 'px'; + updateSendButton(); + }); + + // Suggestion chips + document.querySelectorAll('.suggestion-chip').forEach((chip) => { + chip.addEventListener('click', () => { + messageInput.value = chip.dataset.text; + updateSendButton(); + messageInput.focus(); + }); + }); +} + +async function handleConnect() { + const btnText = connectBtn.querySelector('.btn-text'); + const btnLoader = connectBtn.querySelector('.btn-loader'); + + if (isConnected) { + chrome.runtime.sendMessage({ type: 'DISCONNECT' }); + updateConnectionStatus(false); + } else { + btnText.textContent = 'Connecting...'; + btnLoader.classList.remove('hidden'); + connectBtn.disabled = true; + + chrome.runtime.sendMessage( + { + type: 'CONNECT', + serverUrl: serverUrlInput.value, + }, + (response) => { + btnLoader.classList.add('hidden'); + connectBtn.disabled = false; + + if (response && response.success) { + updateConnectionStatus(true); + } else { + updateConnectionStatus(false); + showError(response?.error || 'Connection failed'); + } + } + ); + } +} + +function updateConnectionStatus(connected) { + isConnected = connected; + + // Update settings page + connectionDot.classList.toggle('connected', connected); + connectionText.textContent = connected ? 'Connected' : 'Disconnected'; + + const btnText = connectBtn.querySelector('.btn-text'); + btnText.textContent = connected ? 'Disconnect' : 'Connect'; + + // Update chat page + statusHint.classList.toggle('connected', connected); + statusHint.innerHTML = connected + ? 'Connected' + : 'Not connected'; + + updateSendButton(); +} + +function updateSendButton() { + const hasText = messageInput.value.trim().length > 0; + sendBtn.disabled = !hasText || !isConnected; +} + +async function sendMessage() { + const text = messageInput.value.trim(); + if (!text || !isConnected) return; + + // Clear input + messageInput.value = ''; + messageInput.style.height = 'auto'; + updateSendButton(); + + // Hide welcome message + const welcome = messagesContainer.querySelector('.welcome-message'); + if (welcome) welcome.remove(); + + // Add user message + addMessage('user', text); + + // Add agent response placeholder + const agentMsgId = addAgentMessage(); + + // Send to background + chrome.runtime.sendMessage({ + type: 'EXECUTE_TASK', + task: text, + tabId: currentTabId, + url: currentTabUrl, + }); +} + +function addMessage(type, text) { + const msgDiv = document.createElement('div'); + msgDiv.className = `message message-${type}`; + msgDiv.innerHTML = ` +
+
${escapeHtml(text)}
+
+ `; + messagesContainer.appendChild(msgDiv); + scrollToBottom(); + return msgDiv; +} + +function addAgentMessage() { + const msgId = 'agent-msg-' + Date.now(); + const msgDiv = document.createElement('div'); + msgDiv.className = 'message message-agent'; + msgDiv.id = msgId; + msgDiv.innerHTML = ` +
+
+
+
+
+
+ +
+ `; + messagesContainer.appendChild(msgDiv); + scrollToBottom(); + return msgId; +} + +function addActionStep(msgId, action, status = 'running') { + const msgDiv = + document.getElementById(msgId) || + document.querySelector('.message-agent:last-child'); + if (!msgDiv) return; + + const stepsContainer = msgDiv.querySelector('.action-steps'); + const typingIndicator = msgDiv.querySelector('.typing-indicator'); + + // Hide typing indicator, show steps + if (typingIndicator) typingIndicator.style.display = 'none'; + stepsContainer.style.display = 'block'; + + const stepId = 'step-' + Date.now(); + const stepDiv = document.createElement('div'); + stepDiv.className = 'action-step'; + stepDiv.id = stepId; + stepDiv.innerHTML = ` +
+ ${getStatusIcon(status)} +
+
+
${escapeHtml(action.name || action)}
+ ${action.detail ? `
${escapeHtml(action.detail)}
` : ''} +
+ `; + stepsContainer.appendChild(stepDiv); + scrollToBottom(); + return stepId; +} + +function updateActionStep(stepId, status) { + const stepDiv = document.getElementById(stepId); + if (!stepDiv) return; + + const iconDiv = stepDiv.querySelector('.action-icon'); + iconDiv.className = `action-icon ${status}`; + iconDiv.innerHTML = getStatusIcon(status); +} + +function completeAgentMessage(msgId, text) { + const msgDiv = + document.getElementById(msgId) || + document.querySelector('.message-agent:last-child'); + if (!msgDiv) return; + + const typingIndicator = msgDiv.querySelector('.typing-indicator'); + if (typingIndicator) typingIndicator.remove(); + + if (text) { + const content = msgDiv.querySelector('.message-content'); + const textDiv = document.createElement('div'); + textDiv.className = 'message-text'; + textDiv.style.marginTop = '12px'; + textDiv.textContent = text; + content.appendChild(textDiv); + } + + scrollToBottom(); +} + +function getStatusIcon(status) { + switch (status) { + case 'running': + return ''; + case 'success': + return ''; + case 'error': + return ''; + default: + return ''; + } +} + +function handleBackgroundMessage(message) { + console.log('Message from background:', message); + + switch (message.type) { + case 'CONNECTION_STATUS': + updateConnectionStatus(message.connected); + break; + + case 'LOG': + handleLogMessage(message); + break; + + case 'ACTION': + const stepId = addActionStep( + null, + { + name: message.action, + detail: message.detail, + }, + 'running' + ); + // Store step ID for later update + window.currentStepId = stepId; + break; + + case 'ACTION_COMPLETE': + if (window.currentStepId) { + updateActionStep( + window.currentStepId, + message.success ? 'success' : 'error' + ); + } + break; + + case 'TASK_COMPLETE': + completeAgentMessage(null, message.result); + break; + + case 'TASK_ERROR': + if (window.currentStepId) { + updateActionStep(window.currentStepId, 'error'); + } + completeAgentMessage(null, 'Error: ' + message.error); + break; + } +} + +function handleLogMessage(message) { + const level = message.level || 'info'; + const text = message.message; + + // Parse action from log + if (text.includes('CDP command:') || text.includes('Executing:')) { + const actionName = text.split(':').pop().trim(); + const stepId = addActionStep(null, { name: actionName }, 'running'); + window.currentStepId = stepId; + } else if ( + text.includes('success') || + text.includes('Success') || + text.includes('Clicked') || + text.includes('Typed') + ) { + if (window.currentStepId) { + updateActionStep(window.currentStepId, 'success'); + } + // Add new step for the action + addActionStep(null, { name: text }, 'success'); + } else if (level === 'error') { + if (window.currentStepId) { + updateActionStep(window.currentStepId, 'error'); + } + addActionStep(null, { name: text }, 'error'); + } else if ( + text.includes('AI') || + text.includes('Analyzing') || + text.includes('Processing') + ) { + addActionStep(null, { name: text }, 'running'); + } +} + +function showError(message) { + // Add error message to chat + const errorDiv = document.createElement('div'); + errorDiv.className = 'message message-agent'; + errorDiv.innerHTML = ` +
+
${escapeHtml(message)}
+
+ `; + messagesContainer.appendChild(errorDiv); + scrollToBottom(); +} + +function scrollToBottom() { + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +function truncateUrl(url, maxLen) { + if (url.length <= maxLen) return url; + return url.substring(0, maxLen - 3) + '...'; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/extension/sidepanel.css b/extension/sidepanel.css new file mode 100644 index 000000000..3899129bd --- /dev/null +++ b/extension/sidepanel.css @@ -0,0 +1,1067 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-tertiary: #f1f3f5; + --text-primary: #212529; + --text-secondary: #6c757d; + --text-tertiary: #adb5bd; + --border-color: #e9ecef; + --accent: #228be6; + --accent-light: #e7f5ff; + --success: #40c057; + --error: #fa5252; + --warning: #fab005; +} + +html, +body { + height: 100%; + font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', + sans-serif; + font-size: 14px; + color: var(--text-primary); + background: var(--bg-primary); +} + +.app { + display: flex; + flex-direction: column; + height: 100%; + position: relative; +} + +/* Header */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-primary); + flex-shrink: 0; +} + +.header-left { + display: flex; + align-items: center; + gap: 10px; +} + +.header-right { + display: flex; + align-items: center; + gap: 4px; +} + +.logo { + color: var(--accent); +} + +.title { + font-weight: 600; + font-size: 15px; +} + +.icon-btn { + width: 32px; + height: 32px; + border: none; + background: transparent; + border-radius: 8px; + cursor: pointer; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.icon-btn:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +/* Settings Panel */ +.settings-panel { + position: absolute; + top: 0; + right: 0; + width: 100%; + max-width: 320px; + height: 100%; + background: var(--bg-primary); + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.1); + z-index: 100; + display: flex; + flex-direction: column; + transform: translateX(0); + transition: transform 0.3s ease; +} + +.settings-panel.hidden { + transform: translateX(100%); +} + +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-color); +} + +.settings-header h2 { + font-size: 16px; + font-weight: 600; +} + +.settings-content { + flex: 1; + padding: 16px; + overflow-y: auto; +} + +.setting-group { + margin-bottom: 20px; +} + +.setting-group label { + display: block; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.connection-row { + display: flex; + align-items: center; + gap: 8px; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--error); + flex-shrink: 0; +} + +.status-dot.connected { + background: var(--success); +} + +.connection-row span:not(.status-dot) { + flex: 1; + font-weight: 500; +} + +.input { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 13px; + transition: border-color 0.2s; +} + +.input:focus { + outline: none; + border-color: var(--accent); +} + +.current-page { + font-size: 12px; + color: var(--text-secondary); + word-break: break-all; + padding: 10px 12px; + background: var(--bg-secondary); + border-radius: 8px; + line-height: 1.4; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 14px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover { + background: #1c7ed6; +} + +.btn-primary.connected { + background: var(--text-tertiary); +} + +/* Chat Area */ +.chat-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.messages-container { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.welcome-message { + text-align: center; + padding: 30px 16px; +} + +.welcome-icon { + color: var(--accent); + margin-bottom: 12px; +} + +.welcome-message h3 { + font-size: 15px; + font-weight: 600; + margin-bottom: 6px; +} + +.welcome-message p { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 16px; +} + +.suggestions { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: center; +} + +.suggestion-chip { + padding: 6px 10px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 14px; + font-size: 11px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.2s; +} + +.suggestion-chip:hover { + background: var(--accent-light); + border-color: var(--accent); + color: var(--accent); +} + +/* Message Bubbles */ +.message { + margin-bottom: 12px; + animation: fadeInUp 0.3s ease; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.message-user { + display: flex; + justify-content: flex-end; +} + +.message-user .message-content { + background: var(--accent); + color: white; + border-radius: 14px 14px 4px 14px; + padding: 10px 12px; + max-width: 85%; +} + +.message-agent { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.message-agent .message-content { + background: var(--bg-secondary); + border-radius: 14px 14px 14px 4px; + padding: 12px; + max-width: 95%; + width: 100%; +} + +.message-text { + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +/* Streaming text */ +.streaming-text { + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + display: none; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-color); +} + +.streaming-text:not(:empty) { + display: block; +} + +/* Typing cursor animation for streaming */ +.streaming-text:not(.message-text)::after { + content: '▋'; + animation: blink 1s step-end infinite; + color: var(--accent); +} + +@keyframes blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +/* Actions Container - Collapsible */ +.actions-container { + margin-top: 8px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + display: none; /* Hidden by default, shown when first action is added */ +} + +.actions-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background: var(--bg-tertiary); + cursor: pointer; + user-select: none; + transition: background 0.2s; +} + +.actions-header:hover { + background: var(--border-color); +} + +.actions-header-left { + display: flex; + align-items: center; + gap: 8px; +} + +.actions-status-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; +} + +.actions-status-icon.running { + color: var(--accent); + animation: spin 1s linear infinite; +} + +.actions-status-icon.success { + color: var(--success); +} + +.actions-status-icon.error { + color: var(--error); +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.actions-title { + font-size: 12px; + font-weight: 500; + color: var(--text-primary); +} + +.actions-count { + font-size: 11px; + color: var(--text-tertiary); + margin-left: 4px; +} + +.actions-toggle { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-tertiary); + transition: transform 0.2s; +} + +.actions-container.expanded .actions-toggle { + transform: rotate(180deg); +} + +/* Actions Body */ +.actions-body { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease-out; +} + +.actions-container.expanded .actions-body { + max-height: 200px; + overflow-y: auto !important; + overflow-x: hidden; +} + +/* Scrollbar for actions list */ +.actions-body::-webkit-scrollbar { + width: 6px; +} + +.actions-body::-webkit-scrollbar-track { + background: var(--bg-tertiary); + border-radius: 3px; +} + +.actions-body::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 3px; + min-height: 30px; +} + +.actions-body::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +.actions-list { + padding: 8px; + min-height: fit-content; +} + +/* Current Action Display (when collapsed) */ +.current-action-display { + padding: 8px 10px; + border-top: 1px solid var(--border-color); + position: relative; + overflow: hidden; + min-height: 32px; +} + +.current-action-display:empty { + display: none; +} + +.actions-container.expanded .current-action-display { + display: none; +} + +.current-action { + display: flex; + align-items: center; + gap: 8px; + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.current-action-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.current-action-icon.running { + color: var(--accent); + animation: pulse 1.5s ease infinite; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +.current-action-icon.success { + color: var(--success); +} + +.current-action-icon.error { + color: var(--error); +} + +.current-action-icon svg { + width: 12px; + height: 12px; +} + +.current-action-text { + font-size: 11px; + color: var(--text-secondary); + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Previous action fade effect */ +.previous-action { + position: absolute; + top: 0; + left: 0; + right: 0; + padding: 8px 10px; + display: flex; + align-items: center; + gap: 8px; + animation: fadeOut 0.3s ease forwards; + pointer-events: none; +} + +.previous-action .current-action-icon { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.previous-action .current-action-text { + font-size: 11px; + color: var(--text-secondary); +} + +@keyframes fadeOut { + from { + opacity: 0.6; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(10px); + } +} + +/* Action Step in expanded list */ +.action-step { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 0; + border-bottom: 1px solid var(--border-color); +} + +.action-step:last-child { + border-bottom: none; +} + +.action-icon { + width: 16px; + height: 16px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 1px; +} + +.action-icon.pending { + background: var(--bg-tertiary); + color: var(--text-tertiary); +} + +.action-icon.running { + background: var(--accent-light); + color: var(--accent); + animation: pulse 1.5s ease infinite; +} + +.action-icon.success { + background: #d3f9d8; + color: var(--success); +} + +.action-icon.error { + background: #ffe3e3; + color: var(--error); +} + +.action-icon svg { + width: 10px; + height: 10px; +} + +.action-info { + flex: 1; + min-width: 0; +} + +.action-name { + font-size: 11px; + font-weight: 500; + color: var(--text-primary); +} + +.action-time { + font-size: 10px; + color: var(--text-tertiary); + margin-left: 6px; +} + +/* Typing Indicator */ +.typing-indicator { + display: flex; + gap: 4px; + padding: 8px 0; +} + +.typing-dot { + width: 6px; + height: 6px; + background: var(--text-tertiary); + border-radius: 50%; + animation: typingBounce 1.4s ease infinite; +} + +.typing-dot:nth-child(2) { + animation-delay: 0.2s; +} +.typing-dot:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typingBounce { + 0%, + 60%, + 100% { + transform: translateY(0); + } + 30% { + transform: translateY(-3px); + } +} + +/* Input Area */ +.input-area { + padding: 12px 16px 16px; + border-top: 1px solid var(--border-color); + background: var(--bg-primary); + flex-shrink: 0; +} + +.input-wrapper { + display: flex; + align-items: flex-end; + gap: 8px; + background: var(--bg-secondary); + border-radius: 18px; + padding: 4px 4px 4px 14px; + border: 1px solid var(--border-color); + transition: border-color 0.2s; +} + +.input-wrapper:focus-within { + border-color: var(--accent); +} + +#messageInput { + flex: 1; + border: none; + background: transparent; + font-size: 13px; + line-height: 1.4; + resize: none; + max-height: 80px; + padding: 6px 0; + font-family: inherit; +} + +#messageInput:focus { + outline: none; +} + +#messageInput::placeholder { + color: var(--text-tertiary); +} + +.send-btn { + width: 32px; + height: 32px; + border: none; + background: var(--accent); + border-radius: 50%; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; +} + +.send-btn:hover:not(:disabled) { + background: #1c7ed6; + transform: scale(1.05); +} + +.send-btn:disabled { + background: var(--text-tertiary); + cursor: not-allowed; +} + +.send-btn svg { + width: 16px; + height: 16px; +} + +.send-btn.stop-mode { + background: var(--error); +} + +.send-btn.stop-mode:hover:not(:disabled) { + background: #e03131; +} + +.input-hint { + margin-top: 6px; + text-align: center; +} + +.status-hint { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--text-tertiary); +} + +.hint-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--error); +} + +.status-hint.connected .hint-dot { + background: var(--success); +} + +.status-hint.connected { + color: var(--success); +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 5px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); +} + +/* Debug Mode Toggle */ +.debug-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; + color: var(--text-primary); +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border-color); + transition: 0.3s; + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ''; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.3s; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.toggle-switch input:checked + .toggle-slider { + background-color: var(--accent); +} + +.toggle-switch input:checked + .toggle-slider:before { + transform: translateX(20px); +} + +/* Debug Panel */ +.debug-panel { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 50%; + background: var(--bg-primary); + border-top: 1px solid var(--border-color); + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1); + z-index: 99; + display: flex; + flex-direction: column; + transform: translateY(0); + transition: transform 0.3s ease; +} + +.debug-panel.hidden { + transform: translateY(100%); +} + +.debug-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.debug-title { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 6px; +} + +.debug-title::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + background: var(--warning); + border-radius: 50%; +} + +.debug-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.debug-output { + flex: 1; + overflow-y: auto; + padding: 12px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 11px; + line-height: 1.5; + background: #1e1e1e; + color: #d4d4d4; +} + +.debug-welcome { + color: var(--text-tertiary); + margin-bottom: 12px; +} + +.debug-welcome p { + margin-bottom: 8px; + color: #569cd6; +} + +.debug-welcome ul { + list-style: none; + padding-left: 0; +} + +.debug-welcome li { + margin-bottom: 4px; + color: #9cdcfe; +} + +.debug-welcome code { + background: #333; + padding: 1px 4px; + border-radius: 3px; + color: #ce9178; +} + +.debug-line { + margin-bottom: 4px; + white-space: pre-wrap; + word-break: break-all; +} + +.debug-line.command { + color: #4ec9b0; +} + +.debug-line.command::before { + content: '> '; + color: #6a9955; +} + +.debug-line.result { + color: #d4d4d4; + padding-left: 12px; +} + +.debug-line.error { + color: #f14c4c; +} + +.debug-line.success { + color: #4ec9b0; +} + +.debug-line.info { + color: #569cd6; +} + +.debug-input-area { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); +} + +.debug-input { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 12px; + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + background: var(--bg-primary); +} + +.debug-input:focus { + outline: none; + border-color: var(--accent); +} + +.debug-send-btn { + width: 32px; + height: 32px; + border: none; + background: var(--warning); + border-radius: 6px; + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; +} + +.debug-send-btn:hover { + background: #e09f00; +} + +.debug-send-btn:disabled { + background: var(--text-tertiary); + cursor: not-allowed; +} diff --git a/extension/sidepanel.html b/extension/sidepanel.html new file mode 100644 index 000000000..19087e2b5 --- /dev/null +++ b/extension/sidepanel.html @@ -0,0 +1,186 @@ + + + + + + CAMEL Browser Agent + + + +
+ +
+
+ + CAMEL Browser Agent +
+
+ + +
+
+ + + + + + + + +
+ +
+
+
+ + + + + + +
+

Welcome to CAMEL Browser Agent

+

Describe what you want to do on this page.

+
+ + + + +
+
+
+ + +
+
+ + +
+
+ + + Not connected + +
+
+
+
+ + + + diff --git a/extension/sidepanel.js b/extension/sidepanel.js new file mode 100644 index 000000000..efffc5d55 --- /dev/null +++ b/extension/sidepanel.js @@ -0,0 +1,914 @@ +// State +let isConnected = false; +let currentTabId = null; +let currentTabUrl = ''; +let isDebugMode = false; +let fullVisionMode = false; +let isTaskRunning = false; + +// Message queue - queue messages while task is running +let messageQueue = []; + +// Output truncation +const MAX_OUTPUT_LENGTH = 120000; +function truncateOutput(text, maxLen = MAX_OUTPUT_LENGTH) { + if (typeof text !== 'string') return text; + if (text.length <= maxLen) return text; + return text.substring(0, maxLen) + '...[truncated]'; +} + +// DOM Elements +const settingsPanel = document.getElementById('settingsPanel'); +const settingsBtn = document.getElementById('settingsBtn'); +const closeSettings = document.getElementById('closeSettings'); +const clearBtn = document.getElementById('clearBtn'); +const connectBtn = document.getElementById('connectBtn'); +const connectionDot = document.getElementById('connectionDot'); +const connectionText = document.getElementById('connectionText'); +const serverUrlInput = document.getElementById('serverUrl'); +const currentPageUrl = document.getElementById('currentPageUrl'); +const messagesContainer = document.getElementById('messagesContainer'); +const messageInput = document.getElementById('messageInput'); +const sendBtn = document.getElementById('sendBtn'); +const statusHint = document.getElementById('statusHint'); +const debugModeToggle = document.getElementById('debugModeToggle'); +const debugPanel = document.getElementById('debugPanel'); +const closeDebug = document.getElementById('closeDebug'); +const debugInput = document.getElementById('debugInput'); +const debugSendBtn = document.getElementById('debugSendBtn'); +const debugOutput = document.getElementById('debugOutput'); +const fullVisionToggle = document.getElementById('fullVisionToggle'); + +// Initialize +document.addEventListener('DOMContentLoaded', async () => { + await updateCurrentTab(); + + // Restore settings from chrome.storage + chrome.storage.local.get( + ['serverUrl', 'fullVisionMode', 'debugMode'], + (result) => { + if (result.serverUrl && serverUrlInput) { + serverUrlInput.value = result.serverUrl; + } + if (result.fullVisionMode !== undefined && fullVisionToggle) { + fullVisionMode = result.fullVisionMode; + fullVisionToggle.checked = fullVisionMode; + } + if (result.debugMode !== undefined && debugModeToggle) { + isDebugMode = result.debugMode; + debugModeToggle.checked = isDebugMode; + if (isDebugMode) debugPanel.classList.remove('hidden'); + } + } + ); + + // Listen for tab changes + chrome.tabs.onActivated.addListener(updateCurrentTab); + chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.url) updateCurrentTab(); + }); + + // Check connection status + chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (response) => { + if (response && response.connected) { + updateConnectionStatus(true); + } + }); + + // Listen for messages from background + chrome.runtime.onMessage.addListener(handleBackgroundMessage); + + // Setup event listeners + setupEventListeners(); +}); + +async function updateCurrentTab() { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab) { + currentTabId = tab.id; + currentTabUrl = tab.url; + currentPageUrl.textContent = tab.url; + currentPageUrl.title = tab.url; + } +} + +function setupEventListeners() { + // Settings toggle + settingsBtn.addEventListener('click', () => { + settingsPanel.classList.remove('hidden'); + }); + + closeSettings.addEventListener('click', () => { + settingsPanel.classList.add('hidden'); + }); + + // Clear chat + clearBtn.addEventListener('click', clearChat); + + // Connect button + connectBtn.addEventListener('click', handleConnect); + + // Send message + sendBtn.addEventListener('click', sendMessage); + messageInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage(); + } + }); + + // Auto-resize textarea + messageInput.addEventListener('input', () => { + messageInput.style.height = 'auto'; + messageInput.style.height = Math.min(messageInput.scrollHeight, 80) + 'px'; + updateSendButton(); + }); + + // Suggestion chips + document.querySelectorAll('.suggestion-chip').forEach((chip) => { + chip.addEventListener('click', () => { + messageInput.value = chip.dataset.text; + updateSendButton(); + messageInput.focus(); + }); + }); + + // Full vision mode toggle + fullVisionToggle.addEventListener('change', (e) => { + fullVisionMode = e.target.checked; + chrome.storage.local.set({ fullVisionMode }); + chrome.runtime.sendMessage({ + type: 'SET_FULL_VISION', + enabled: fullVisionMode, + }); + }); + + // Debug mode toggle + debugModeToggle.addEventListener('change', (e) => { + isDebugMode = e.target.checked; + chrome.storage.local.set({ debugMode: isDebugMode }); + if (isDebugMode) { + debugPanel.classList.remove('hidden'); + settingsPanel.classList.add('hidden'); + } else { + debugPanel.classList.add('hidden'); + } + }); + + // Close debug panel + closeDebug.addEventListener('click', () => { + debugPanel.classList.add('hidden'); + isDebugMode = false; + debugModeToggle.checked = false; + }); + + // Debug command input + debugSendBtn.addEventListener('click', sendDebugCommand); + debugInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + sendDebugCommand(); + } + }); +} + +async function handleConnect() { + if (isConnected) { + chrome.runtime.sendMessage({ type: 'DISCONNECT' }); + updateConnectionStatus(false); + } else { + connectBtn.textContent = 'Connecting...'; + connectBtn.disabled = true; + + const urlToConnect = serverUrlInput.value; + chrome.storage.local.set({ serverUrl: urlToConnect }); + chrome.runtime.sendMessage( + { + type: 'CONNECT', + serverUrl: urlToConnect, + }, + (response) => { + connectBtn.disabled = false; + + if (response && response.success) { + updateConnectionStatus(true); + } else { + updateConnectionStatus(false); + showSystemMessage( + 'Connection failed: ' + (response?.error || 'Unknown error'), + 'error' + ); + } + } + ); + } +} + +function updateConnectionStatus(connected) { + isConnected = connected; + + // Update settings panel + connectionDot.classList.toggle('connected', connected); + connectionText.textContent = connected ? 'Connected' : 'Disconnected'; + connectBtn.textContent = connected ? 'Disconnect' : 'Connect'; + connectBtn.classList.toggle('connected', connected); + + // Update status hint + statusHint.classList.toggle('connected', connected); + statusHint.querySelector('.hint-text').textContent = connected + ? 'Connected' + : 'Not connected'; + + updateSendButton(); +} + +function updateSendButton() { + if (isTaskRunning) { + const hasText = messageInput.value.trim().length > 0; + // Keep input enabled for queueing + messageInput.disabled = false; + + if (hasText) { + // Show send icon (will queue the message) + sendBtn.classList.remove('stop-mode'); + sendBtn.innerHTML = ` + + + + + `; + sendBtn.disabled = !isConnected; + } else { + // Show stop icon when no text + sendBtn.disabled = false; + sendBtn.classList.add('stop-mode'); + sendBtn.innerHTML = ` + + + + `; + } + + // Show queue badge + updateQueueBadge(); + } else { + sendBtn.classList.remove('stop-mode'); + sendBtn.innerHTML = ` + + + + + `; + messageInput.disabled = false; + const hasText = messageInput.value.trim().length > 0; + sendBtn.disabled = !hasText || !isConnected; + updateQueueBadge(); + } +} + +function updateQueueBadge() { + let badge = document.getElementById('queueBadge'); + if (messageQueue.length > 0) { + if (!badge) { + badge = document.createElement('span'); + badge.id = 'queueBadge'; + badge.style.cssText = + 'position:absolute;top:-6px;right:-6px;background:var(--primary);color:white;border-radius:50%;width:18px;height:18px;font-size:11px;display:flex;align-items:center;justify-content:center;font-weight:600;'; + sendBtn.style.position = 'relative'; + sendBtn.appendChild(badge); + } + badge.textContent = messageQueue.length; + } else if (badge) { + badge.remove(); + } +} + +function setTaskRunning(running) { + isTaskRunning = running; + updateSendButton(); +} + +async function sendMessage() { + const text = messageInput.value.trim(); + + // If task is running + if (isTaskRunning) { + if (text) { + // Queue the message + messageQueue.push(text); + messageInput.value = ''; + messageInput.style.height = 'auto'; + showSystemMessage( + `Message queued (${messageQueue.length} in queue)`, + 'info' + ); + updateSendButton(); + return; + } else { + // No text = stop task (stop button) + chrome.runtime.sendMessage({ type: 'STOP_TASK', tabId: currentTabId }); + setTaskRunning(false); + messageQueue = []; // Clear queue on stop + completeAgentMessage('Task stopped by user.'); + updateSendButton(); + return; + } + } + + if (!text || !isConnected) return; + + await executeMessage(text); +} + +async function executeMessage(text) { + // Update current tab info before sending + await updateCurrentTab(); + + // Clear input + messageInput.value = ''; + messageInput.style.height = 'auto'; + + // Set task running + setTaskRunning(true); + + // Hide welcome message + const welcome = messagesContainer.querySelector('.welcome-message'); + if (welcome) welcome.remove(); + + // Add user message + addMessage('user', text); + + // Add agent response placeholder + addAgentMessage(); + + // Send to background + chrome.runtime.sendMessage({ + type: 'EXECUTE_TASK', + task: text, + tabId: currentTabId, + url: currentTabUrl, + fullVisionMode: fullVisionMode, + }); +} + +// Process next message in queue +function processMessageQueue() { + if (messageQueue.length > 0 && !isTaskRunning && isConnected) { + const nextMessage = messageQueue.shift(); + updateQueueBadge(); + executeMessage(nextMessage); + } +} + +function addMessage(type, text) { + const msgDiv = document.createElement('div'); + msgDiv.className = `message message-${type}`; + msgDiv.innerHTML = ` +
+
${escapeHtml(text)}
+
+ `; + messagesContainer.appendChild(msgDiv); + scrollToBottom(); + return msgDiv; +} + +function addAgentMessage() { + const msgId = 'agent-msg-' + Date.now(); + const msgDiv = document.createElement('div'); + msgDiv.className = 'message message-agent'; + msgDiv.id = msgId; + msgDiv.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+ + + +
+ Running Actions + (0) +
+
+ + + +
+
+
+
+
+
+
+
+
+ `; + messagesContainer.appendChild(msgDiv); + + // Add click listener for expand/collapse + const header = msgDiv.querySelector('.actions-header'); + header.addEventListener('click', () => { + const container = header.closest('.actions-container'); + container.classList.toggle('expanded'); + }); + + // Store action count + msgDiv.actionCount = 0; + + scrollToBottom(); + return msgId; +} + +function addActionStep(action, status = 'running') { + const msgDiv = document.querySelector('.message-agent:last-child'); + if (!msgDiv) return null; + + const actionsContainer = msgDiv.querySelector('.actions-container'); + const actionsList = msgDiv.querySelector('.actions-list'); + const currentActionDisplay = msgDiv.querySelector('.current-action-display'); + const typingIndicator = msgDiv.querySelector('.typing-indicator'); + + // Hide typing indicator + if (typingIndicator) typingIndicator.style.display = 'none'; + + // Show actions container + if (actionsContainer) { + actionsContainer.style.display = 'block'; + } + + const stepId = 'step-' + Date.now(); + const actionName = escapeHtml( + typeof action === 'string' ? action : action.name + ); + const actionTime = new Date().toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + // Create action step for the expanded list + const stepDiv = document.createElement('div'); + stepDiv.className = 'action-step'; + stepDiv.id = stepId; + stepDiv.innerHTML = ` +
+ ${getStatusIcon(status)} +
+
+
${actionName}${actionTime}
+
+ `; + actionsList.appendChild(stepDiv); + + // Update action count + msgDiv.actionCount = (msgDiv.actionCount || 0) + 1; + const countSpan = msgDiv.querySelector('.actions-count'); + if (countSpan) { + countSpan.textContent = `(${msgDiv.actionCount})`; + } + + // Update current action display with animation (only when collapsed) + if (currentActionDisplay) { + // Get previous action if exists + const prevAction = currentActionDisplay.querySelector('.current-action'); + + if (prevAction) { + // Move previous to fade out + prevAction.classList.remove('current-action'); + prevAction.classList.add('previous-action'); + + // Remove after animation + setTimeout(() => prevAction.remove(), 300); + } + + // Add new current action + const newAction = document.createElement('div'); + newAction.className = 'current-action'; + newAction.innerHTML = ` +
+ ${getStatusIcon(status)} +
+ ${actionName} + `; + newAction.dataset.stepId = stepId; + currentActionDisplay.appendChild(newAction); + } + + // Update header status icon + updateActionsHeaderStatus(msgDiv, status); + + scrollToBottom(); + return stepId; +} + +function updateActionStep(stepId, status) { + const stepDiv = document.getElementById(stepId); + if (!stepDiv) return; + + // Update in actions list + const iconDiv = stepDiv.querySelector('.action-icon'); + iconDiv.className = `action-icon ${status}`; + iconDiv.innerHTML = getStatusIcon(status); + + // Update in current action display if this is the current one + const msgDiv = stepDiv.closest('.message-agent'); + if (msgDiv) { + const currentAction = msgDiv.querySelector( + `.current-action[data-step-id="${stepId}"]` + ); + if (currentAction) { + const currentIcon = currentAction.querySelector('.current-action-icon'); + if (currentIcon) { + currentIcon.className = `current-action-icon ${status}`; + currentIcon.innerHTML = getStatusIcon(status); + } + } + + // Update header status based on overall state + updateActionsHeaderStatus(msgDiv, status); + } +} + +// Update the actions header status icon +function updateActionsHeaderStatus(msgDiv, _latestStatus) { + const statusIcon = msgDiv.querySelector('.actions-status-icon'); + const titleSpan = msgDiv.querySelector('.actions-title'); + + if (!statusIcon) return; + + // Check if any action is still running + const runningActions = msgDiv.querySelectorAll('.action-icon.running'); + const hasRunning = runningActions.length > 0; + + // Check for errors + const errorActions = msgDiv.querySelectorAll('.action-icon.error'); + const hasError = errorActions.length > 0; + + let overallStatus = 'success'; + let title = 'Actions Complete'; + + if (hasRunning) { + overallStatus = 'running'; + title = 'Running Actions'; + } else if (hasError) { + overallStatus = 'error'; + title = 'Actions (with errors)'; + } + + statusIcon.className = `actions-status-icon ${overallStatus}`; + statusIcon.innerHTML = getHeaderStatusIcon(overallStatus); + + if (titleSpan) { + titleSpan.textContent = title; + } +} + +// Get status icon for header +function getHeaderStatusIcon(status) { + switch (status) { + case 'running': + return ''; + case 'success': + return ''; + case 'error': + return ''; + default: + return ''; + } +} + +// Append streaming text to the current agent message +function appendStreamingText(text) { + const msgDiv = document.querySelector('.message-agent:last-child'); + if (!msgDiv) return; + + const typingIndicator = msgDiv.querySelector('.typing-indicator'); + if (typingIndicator) typingIndicator.style.display = 'none'; + + let streamingDiv = msgDiv.querySelector('.streaming-text'); + if (!streamingDiv) { + const content = msgDiv.querySelector('.message-content'); + streamingDiv = document.createElement('div'); + streamingDiv.className = 'streaming-text'; + content.appendChild(streamingDiv); + } + + // Show the streaming div + streamingDiv.style.display = 'block'; + + // Append text with typing effect + streamingDiv.textContent += text; + scrollToBottom(); +} + +function completeAgentMessage(text) { + const msgDiv = document.querySelector('.message-agent:last-child'); + if (!msgDiv) return; + + const typingIndicator = msgDiv.querySelector('.typing-indicator'); + if (typingIndicator) typingIndicator.remove(); + + // Update header to show completion + const actionsContainer = msgDiv.querySelector('.actions-container'); + if (actionsContainer) { + // Mark all running actions as complete + const runningIcons = msgDiv.querySelectorAll('.action-icon.running'); + runningIcons.forEach((icon) => { + icon.className = 'action-icon success'; + icon.innerHTML = getStatusIcon('success'); + }); + + // Update current action icons + const runningCurrentIcons = msgDiv.querySelectorAll( + '.current-action-icon.running' + ); + runningCurrentIcons.forEach((icon) => { + icon.className = 'current-action-icon success'; + icon.innerHTML = getStatusIcon('success'); + }); + + // Update header status + updateActionsHeaderStatus(msgDiv, 'success'); + } + + // Check if we have streaming text that should become the final text + const streamingDiv = msgDiv.querySelector('.streaming-text'); + if (streamingDiv && streamingDiv.textContent) { + // Streaming text already contains the content, just style it + streamingDiv.className = 'message-text'; + if (actionsContainer && actionsContainer.style.display !== 'none') { + streamingDiv.style.marginTop = '8px'; + streamingDiv.style.paddingTop = '8px'; + streamingDiv.style.borderTop = '1px solid var(--border-color)'; + } + } else if (text) { + const content = msgDiv.querySelector('.message-content'); + + // Remove empty streaming div if exists + if (streamingDiv) streamingDiv.remove(); + + const textDiv = document.createElement('div'); + textDiv.className = 'message-text'; + if (actionsContainer && actionsContainer.style.display !== 'none') { + textDiv.style.marginTop = '8px'; + textDiv.style.paddingTop = '8px'; + textDiv.style.borderTop = '1px solid var(--border-color)'; + } + textDiv.textContent = text; + content.appendChild(textDiv); + } + + scrollToBottom(); +} + +function showSystemMessage(text, type = 'info') { + const msgDiv = document.createElement('div'); + msgDiv.className = 'message message-agent'; + const bgColor = + type === 'error' + ? '#ffe3e3' + : type === 'success' + ? '#d3f9d8' + : 'var(--bg-secondary)'; + const textColor = + type === 'error' + ? 'var(--error)' + : type === 'success' + ? 'var(--success)' + : 'var(--text-primary)'; + msgDiv.innerHTML = ` +
+
${escapeHtml(text)}
+
+ `; + messagesContainer.appendChild(msgDiv); + scrollToBottom(); +} + +function getStatusIcon(status) { + switch (status) { + case 'running': + return ''; + case 'success': + return ''; + case 'error': + return ''; + default: + return ''; + } +} + +function handleBackgroundMessage(message) { + console.log('Message from background:', message); + + switch (message.type) { + case 'CONNECTION_STATUS': + updateConnectionStatus(message.connected); + if (message.reconnecting) { + statusHint.querySelector('.hint-text').textContent = + `Reconnecting (attempt ${message.attempt})...`; + } else if (message.failed) { + statusHint.querySelector('.hint-text').textContent = + 'Reconnection failed'; + } + break; + + case 'LOG': + handleLogMessage(message); + break; + + case 'ACTION': + window.currentStepId = addActionStep( + { + name: message.action, + detail: message.detail, + }, + 'running' + ); + break; + + case 'ACTION_COMPLETE': + if (window.currentStepId) { + updateActionStep( + window.currentStepId, + message.success ? 'success' : 'error' + ); + } + break; + + case 'STREAM_TEXT': + // Handle streaming text from agent (with truncation) + appendStreamingText(truncateOutput(message.text)); + break; + + case 'STREAM_START': + // Clear any existing streaming text for new stream + const msgDiv = document.querySelector('.message-agent:last-child'); + if (msgDiv) { + const streamingDiv = msgDiv.querySelector('.streaming-text'); + if (streamingDiv) streamingDiv.textContent = ''; + } + break; + + case 'STREAM_END': + // Stream ended, finalize the message + completeAgentMessage(null); + break; + + case 'TASK_COMPLETE': + completeAgentMessage(message.result); + setTaskRunning(false); + // Process next queued message + setTimeout(processMessageQueue, 500); + break; + + case 'TASK_ERROR': + if (window.currentStepId) { + updateActionStep(window.currentStepId, 'error'); + } + completeAgentMessage('Error: ' + message.error); + setTaskRunning(false); + // Process next queued message + setTimeout(processMessageQueue, 500); + break; + + case 'DEBUG_RESULT': + handleDebugResult(message); + break; + } +} + +function handleLogMessage(message) { + const level = message.level || 'info'; + const text = message.message; + + // Parse action from log message + if (text.includes('Executing:')) { + const actionName = text.replace('Executing:', '').trim(); + window.currentStepId = addActionStep({ name: actionName }, 'running'); + } else if (text.includes('Completed:')) { + if (window.currentStepId) { + updateActionStep(window.currentStepId, 'success'); + } + } else if (text.includes('Failed:')) { + if (window.currentStepId) { + updateActionStep(window.currentStepId, 'error'); + } + addActionStep({ name: text }, 'error'); + } else if (level === 'success' && !text.includes('Debugger')) { + addActionStep({ name: text }, 'success'); + } else if (level === 'error') { + addActionStep({ name: text }, 'error'); + } else if ( + text.includes('AI') || + text.includes('Sending task') || + text.includes('Processing') || + text.includes('Analyzing') + ) { + window.currentStepId = addActionStep({ name: text }, 'running'); + } else if (text.includes('Attaching') || text.includes('attached')) { + addActionStep({ name: text }, level === 'success' ? 'success' : 'running'); + } +} + +function clearChat() { + messagesContainer.innerHTML = ` +
+
+ + + + + + +
+

Welcome to CAMEL Browser Agent

+

Describe what you want to do on this page.

+
+ + + + +
+
+ `; + + // Re-attach suggestion chip listeners + document.querySelectorAll('.suggestion-chip').forEach((chip) => { + chip.addEventListener('click', () => { + messageInput.value = chip.dataset.text; + updateSendButton(); + messageInput.focus(); + }); + }); + + // Notify backend to clear context + if (isConnected) { + chrome.runtime.sendMessage({ type: 'CLEAR_CONTEXT' }); + } +} + +function scrollToBottom() { + messagesContainer.scrollTop = messagesContainer.scrollHeight; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Debug mode functions +async function sendDebugCommand() { + const command = debugInput.value.trim(); + if (!command || !isConnected) return; + + // Clear input + debugInput.value = ''; + + // Add command to output + addDebugLine(command, 'command'); + + // Update current tab info + await updateCurrentTab(); + + // Send to background + chrome.runtime.sendMessage({ + type: 'DEBUG_COMMAND', + command: command, + tabId: currentTabId, + url: currentTabUrl, + }); +} + +function addDebugLine(text, type = 'result') { + const line = document.createElement('div'); + line.className = `debug-line ${type}`; + line.textContent = text; + debugOutput.appendChild(line); + debugOutput.scrollTop = debugOutput.scrollHeight; +} + +function handleDebugResult(message) { + if (message.success) { + if (message.result) { + // Format the result + let resultText = message.result; + if (typeof resultText === 'object') { + resultText = JSON.stringify(resultText, null, 2); + } + addDebugLine(resultText, 'success'); + } else { + addDebugLine('Command executed successfully', 'success'); + } + } else { + addDebugLine(`Error: ${message.error}`, 'error'); + } +} From 549442ef567a1dd9eb77fa874dfd898f3ae9c7e8 Mon Sep 17 00:00:00 2001 From: puzhen <1303385763@qq.com> Date: Wed, 4 Mar 2026 14:31:24 +0000 Subject: [PATCH 19/22] rename extension to extensions --- eslint.config.js | 2 +- {extension => extensions}/background.js | 0 {extension => extensions}/icon.png | Bin {extension => extensions}/icon.svg | 0 {extension => extensions}/manifest.json | 0 {extension => extensions}/popup.css | 0 {extension => extensions}/popup.html | 0 {extension => extensions}/popup.js | 0 {extension => extensions}/sidepanel.css | 0 {extension => extensions}/sidepanel.html | 0 {extension => extensions}/sidepanel.js | 0 11 files changed, 1 insertion(+), 1 deletion(-) rename {extension => extensions}/background.js (100%) rename {extension => extensions}/icon.png (100%) rename {extension => extensions}/icon.svg (100%) rename {extension => extensions}/manifest.json (100%) rename {extension => extensions}/popup.css (100%) rename {extension => extensions}/popup.html (100%) rename {extension => extensions}/popup.js (100%) rename {extension => extensions}/sidepanel.css (100%) rename {extension => extensions}/sidepanel.html (100%) rename {extension => extensions}/sidepanel.js (100%) diff --git a/eslint.config.js b/eslint.config.js index 9c858a3a8..1faeb90cd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -71,7 +71,7 @@ export default [ // Prebuilt resources 'resources/prebuilt/**', // Chrome extension (standalone, uses chrome/browser globals) - 'extension/**', + 'extensions/**', ], }, diff --git a/extension/background.js b/extensions/background.js similarity index 100% rename from extension/background.js rename to extensions/background.js diff --git a/extension/icon.png b/extensions/icon.png similarity index 100% rename from extension/icon.png rename to extensions/icon.png diff --git a/extension/icon.svg b/extensions/icon.svg similarity index 100% rename from extension/icon.svg rename to extensions/icon.svg diff --git a/extension/manifest.json b/extensions/manifest.json similarity index 100% rename from extension/manifest.json rename to extensions/manifest.json diff --git a/extension/popup.css b/extensions/popup.css similarity index 100% rename from extension/popup.css rename to extensions/popup.css diff --git a/extension/popup.html b/extensions/popup.html similarity index 100% rename from extension/popup.html rename to extensions/popup.html diff --git a/extension/popup.js b/extensions/popup.js similarity index 100% rename from extension/popup.js rename to extensions/popup.js diff --git a/extension/sidepanel.css b/extensions/sidepanel.css similarity index 100% rename from extension/sidepanel.css rename to extensions/sidepanel.css diff --git a/extension/sidepanel.html b/extensions/sidepanel.html similarity index 100% rename from extension/sidepanel.html rename to extensions/sidepanel.html diff --git a/extension/sidepanel.js b/extensions/sidepanel.js similarity index 100% rename from extension/sidepanel.js rename to extensions/sidepanel.js From 7bc8d44e562640a02b99f9521ef293795f50b0d5 Mon Sep 17 00:00:00 2001 From: puzhen <1303385763@qq.com> Date: Wed, 4 Mar 2026 14:34:04 +0000 Subject: [PATCH 20/22] move extension files into extensions/chrome_extension/ --- eslint.config.js | 2 +- extensions/{ => chrome_extension}/background.js | 0 extensions/{ => chrome_extension}/icon.png | Bin extensions/{ => chrome_extension}/icon.svg | 0 extensions/{ => chrome_extension}/manifest.json | 0 extensions/{ => chrome_extension}/popup.css | 0 extensions/{ => chrome_extension}/popup.html | 0 extensions/{ => chrome_extension}/popup.js | 0 extensions/{ => chrome_extension}/sidepanel.css | 0 extensions/{ => chrome_extension}/sidepanel.html | 0 extensions/{ => chrome_extension}/sidepanel.js | 0 11 files changed, 1 insertion(+), 1 deletion(-) rename extensions/{ => chrome_extension}/background.js (100%) rename extensions/{ => chrome_extension}/icon.png (100%) rename extensions/{ => chrome_extension}/icon.svg (100%) rename extensions/{ => chrome_extension}/manifest.json (100%) rename extensions/{ => chrome_extension}/popup.css (100%) rename extensions/{ => chrome_extension}/popup.html (100%) rename extensions/{ => chrome_extension}/popup.js (100%) rename extensions/{ => chrome_extension}/sidepanel.css (100%) rename extensions/{ => chrome_extension}/sidepanel.html (100%) rename extensions/{ => chrome_extension}/sidepanel.js (100%) diff --git a/eslint.config.js b/eslint.config.js index 1faeb90cd..fd85d7c3d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -71,7 +71,7 @@ export default [ // Prebuilt resources 'resources/prebuilt/**', // Chrome extension (standalone, uses chrome/browser globals) - 'extensions/**', + 'extensions/chrome_extension/**', ], }, diff --git a/extensions/background.js b/extensions/chrome_extension/background.js similarity index 100% rename from extensions/background.js rename to extensions/chrome_extension/background.js diff --git a/extensions/icon.png b/extensions/chrome_extension/icon.png similarity index 100% rename from extensions/icon.png rename to extensions/chrome_extension/icon.png diff --git a/extensions/icon.svg b/extensions/chrome_extension/icon.svg similarity index 100% rename from extensions/icon.svg rename to extensions/chrome_extension/icon.svg diff --git a/extensions/manifest.json b/extensions/chrome_extension/manifest.json similarity index 100% rename from extensions/manifest.json rename to extensions/chrome_extension/manifest.json diff --git a/extensions/popup.css b/extensions/chrome_extension/popup.css similarity index 100% rename from extensions/popup.css rename to extensions/chrome_extension/popup.css diff --git a/extensions/popup.html b/extensions/chrome_extension/popup.html similarity index 100% rename from extensions/popup.html rename to extensions/chrome_extension/popup.html diff --git a/extensions/popup.js b/extensions/chrome_extension/popup.js similarity index 100% rename from extensions/popup.js rename to extensions/chrome_extension/popup.js diff --git a/extensions/sidepanel.css b/extensions/chrome_extension/sidepanel.css similarity index 100% rename from extensions/sidepanel.css rename to extensions/chrome_extension/sidepanel.css diff --git a/extensions/sidepanel.html b/extensions/chrome_extension/sidepanel.html similarity index 100% rename from extensions/sidepanel.html rename to extensions/chrome_extension/sidepanel.html diff --git a/extensions/sidepanel.js b/extensions/chrome_extension/sidepanel.js similarity index 100% rename from extensions/sidepanel.js rename to extensions/chrome_extension/sidepanel.js From 1b02df492cc17c5cbd173cc8a6a4b1273d10f393 Mon Sep 17 00:00:00 2001 From: puzhen <1303385763@qq.com> Date: Wed, 4 Mar 2026 22:52:08 +0000 Subject: [PATCH 21/22] update ui --- backend/app/service/extension_chat_service.py | 59 +++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/backend/app/service/extension_chat_service.py b/backend/app/service/extension_chat_service.py index 5d922999d..55b1fea70 100644 --- a/backend/app/service/extension_chat_service.py +++ b/backend/app/service/extension_chat_service.py @@ -21,6 +21,8 @@ import asyncio import logging +import os +from pathlib import Path import platform import uuid @@ -64,6 +66,15 @@ def _create_chat_agent(wrapper, full_visual_mode: bool = False) -> ChatAgent: "Pass model_config when starting extension proxy." ) + # Setup camel LLM logging (same pattern as chat_controller) + log_dir = ( + Path.home() / ".eigent" / "qwe" / "extension_chat" / "camel_logs" + ) + log_dir.mkdir(parents=True, exist_ok=True) + os.environ["CAMEL_LOG_DIR"] = str(log_dir) + os.environ["CAMEL_MODEL_LOG_ENABLED"] = "true" + logger.info(f"CAMEL_LOG_DIR set to {log_dir}") + extra_params = _model_config.get("extra_params", {}) model_config_dict = {} if extra_params: @@ -97,7 +108,7 @@ def _create_chat_agent(wrapper, full_visual_mode: bool = False) -> ChatAgent: "browser_enter", "browser_visit_page", "browser_scroll", - "browser_open", + "browser_set_trigger", ] if full_visual_mode: enabled_tools.append("browser_get_screenshot") @@ -135,6 +146,14 @@ def _create_chat_agent(wrapper, full_visual_mode: bool = False) -> ChatAgent: "The browser is already open with active sessions and logged-in " "websites.\n" "\n" + "\n\n" + "You have a browser_set_trigger tool. When the user asks you to " + "wait for a condition (e.g., 'click the buy button when it becomes " + "available', 'do something at 12:00'), write a JavaScript arrow " + "function that returns true when the condition is met, and call " + "browser_set_trigger. You will be automatically notified when the " + "condition is met, then proceed with the requested action.\n" + "\n" ), ) @@ -225,9 +244,10 @@ async def _process_chat_message( ) await wrapper.send_chat_response("STREAM_END", {}) - await wrapper.send_chat_response( - "TASK_COMPLETE", {"result": full_text or "Done"} - ) + # Don't re-send full_text — it was already streamed via + # STREAM_TEXT chunks. Sending it again in TASK_COMPLETE causes + # duplicate text in the UI. + await wrapper.send_chat_response("TASK_COMPLETE", {}) except Exception as e: logger.error(f"Chat agent error: {e}", exc_info=True) @@ -243,9 +263,40 @@ async def _chat_loop(wrapper): if msg_data is None: continue + msg_type = msg_data.get("type", "CHAT_MESSAGE") + + if msg_type == "CLEAR_CONTEXT": + await clear_chat_context() + continue + + if msg_type == "TRIGGER_FIRED": + description = msg_data.get("description", "unknown") + trigger_msg = ( + f"[TRIGGER FIRED] Your trigger has been activated: " + f"{description}. Please proceed with the next action." + ) + logger.info(f"Trigger fired: {description}") + await _process_chat_message( + wrapper, + trigger_msg, + full_vision_mode=_current_vision_mode, + ) + continue + message = msg_data.get("message", "") full_vision = msg_data.get("fullVisionMode", False) if message.strip(): + # Prepend current page context + page_url = msg_data.get("url", "") + page_title = msg_data.get("pageTitle", "") + if page_url: + context = ( + f"[Current page: {page_title} | {page_url}]\n" + if page_title + else f"[Current page: {page_url}]\n" + ) + message = context + message + logger.info( f"Processing chat message " f"(vision={full_vision}): {message[:100]}" From d835f44c9cf9e0cfad7ba5e5efea9443b8b136d3 Mon Sep 17 00:00:00 2001 From: Sun Tao <2605127667@qq.com> Date: Thu, 26 Mar 2026 17:12:42 +0800 Subject: [PATCH 22/22] Update index.tsx --- src/components/ChatBox/index.tsx | 76 +++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/src/components/ChatBox/index.tsx b/src/components/ChatBox/index.tsx index dfaf5428b..c8a04e0ab 100644 --- a/src/components/ChatBox/index.tsx +++ b/src/components/ChatBox/index.tsx @@ -18,6 +18,7 @@ import { fetchPut, proxyFetchDelete, proxyFetchGet, + proxyFetchPut, } from '@/api/http'; import useChatStoreAdapter from '@/hooks/useChatStoreAdapter'; import { generateUniqueId, replayActiveTask } from '@/lib'; @@ -44,9 +45,37 @@ const getChatStoreTotalTokens = (chatStore: VanillaChatStore): number => { ); }; +const REQUIRED_PRIVACY_FIELDS = [ + 'take_screenshot', + 'access_local_software', + 'access_your_address', + 'password_storage', +] as const; + +const hasAcceptedPrivacyPolicy = ( + privacySettings: Record | null | undefined +): boolean => { + if (!privacySettings) { + return false; + } + + if (typeof privacySettings.accepted === 'boolean') { + return privacySettings.accepted; + } + + if (typeof privacySettings.privacy === 'boolean') { + return privacySettings.privacy; + } + + return REQUIRED_PRIVACY_FIELDS.every( + (field) => privacySettings[field] === true + ); +}; + export default function ChatBox(): JSX.Element { const [message, setMessage] = useState(''); const [mentionTarget, setMentionTarget] = useState(null); + const [privacy, setPrivacy] = useState(false); //Get Chatstore for the active project's task const { chatStore, projectStore } = useChatStoreAdapter(); @@ -106,6 +135,16 @@ export default function ChatBox(): JSX.Element { } }, [modelType]); + const checkPrivacyConsent = useCallback(async () => { + try { + const res = await proxyFetchGet('/api/v1/user/privacy'); + setPrivacy(hasAcceptedPrivacyPolicy(res)); + } catch (err) { + console.error('Failed to check privacy consent:', err); + setPrivacy(false); + } + }, []); + // Check model config on mount and when modelType changes useEffect(() => { proxyFetchGet('/api/v1/configs') @@ -122,27 +161,30 @@ export default function ChatBox(): JSX.Element { .catch((err) => console.error('Failed to fetch configs:', err)); checkModelConfig(); - }, [modelType, checkModelConfig]); + checkPrivacyConsent(); + }, [modelType, checkModelConfig, checkPrivacyConsent]); // Re-check model config when returning from settings page useEffect(() => { // Check when location changes (user navigates) if (location.pathname === '/') { checkModelConfig(); + checkPrivacyConsent(); } - }, [location.pathname, checkModelConfig]); + }, [location.pathname, checkModelConfig, checkPrivacyConsent]); // Also check when window gains focus (user returns from settings) useEffect(() => { const handleFocus = () => { checkModelConfig(); + checkPrivacyConsent(); }; window.addEventListener('focus', handleFocus); return () => { window.removeEventListener('focus', handleFocus); }; - }, [checkModelConfig]); + }, [checkModelConfig, checkPrivacyConsent]); // Task time tracking const [taskTime, setTaskTime] = useState( @@ -1228,25 +1270,27 @@ export default function ChatBox(): JSX.Element { {hasModel && !privacy ? (
{ + onClick={async (e) => { const target = e.target as HTMLElement; if (target.tagName === 'A') { return; } - const API_FIELDS = [ - 'take_screenshot', - 'access_local_software', - 'access_your_address', - 'password_storage', - ]; const requestData = { - [API_FIELDS[0]]: true, - [API_FIELDS[1]]: true, - [API_FIELDS[2]]: true, - [API_FIELDS[3]]: true, + [REQUIRED_PRIVACY_FIELDS[0]]: true, + [REQUIRED_PRIVACY_FIELDS[1]]: true, + [REQUIRED_PRIVACY_FIELDS[2]]: true, + [REQUIRED_PRIVACY_FIELDS[3]]: true, }; - proxyFetchPut('/api/user/privacy', requestData); - setPrivacy(true); + try { + await proxyFetchPut( + '/api/v1/user/privacy', + requestData + ); + setPrivacy(true); + } catch (err) { + console.error('Failed to update privacy consent:', err); + toast.error('Failed to save privacy consent.'); + } }} className="flex cursor-pointer items-center gap-1 rounded-md bg-surface-information px-sm py-xs" >