diff --git a/surfsense_backend/app/agents/autocomplete/__init__.py b/surfsense_backend/app/agents/autocomplete/__init__.py new file mode 100644 index 000000000..55d7a692d --- /dev/null +++ b/surfsense_backend/app/agents/autocomplete/__init__.py @@ -0,0 +1,11 @@ +"""Agent-based vision autocomplete with scoped filesystem exploration.""" + +from app.agents.autocomplete.autocomplete_agent import ( + create_autocomplete_agent, + stream_autocomplete_agent, +) + +__all__ = [ + "create_autocomplete_agent", + "stream_autocomplete_agent", +] diff --git a/surfsense_backend/app/agents/autocomplete/autocomplete_agent.py b/surfsense_backend/app/agents/autocomplete/autocomplete_agent.py new file mode 100644 index 000000000..c6a071b0f --- /dev/null +++ b/surfsense_backend/app/agents/autocomplete/autocomplete_agent.py @@ -0,0 +1,442 @@ +"""Vision autocomplete agent with scoped filesystem exploration. + +Converts the stateless single-shot vision autocomplete into an agent that +seeds a virtual filesystem from KB search results and lets the vision LLM +explore documents via ``ls``, ``read_file``, ``glob``, ``grep``, etc. +before generating the final completion. + +Performance: KB search and agent graph compilation run in parallel so +the only sequential latency is KB-search (or agent compile, whichever is +slower) + the agent's LLM turns. There is no separate "query extraction" +LLM call — the window title is used directly as the KB search query. +""" + +from __future__ import annotations + +import asyncio +import logging +import uuid +from collections.abc import AsyncGenerator +from typing import Any + +from deepagents.graph import BASE_AGENT_PROMPT +from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware +from langchain.agents import create_agent +from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware +from langchain_core.language_models import BaseChatModel +from langchain_core.messages import AIMessage, ToolMessage + +from app.agents.new_chat.middleware.filesystem import SurfSenseFilesystemMiddleware +from app.agents.new_chat.middleware.knowledge_search import ( + build_scoped_filesystem, + search_knowledge_base, +) +from app.services.new_streaming_service import VercelStreamingService + +logger = logging.getLogger(__name__) + +KB_TOP_K = 10 + +# --------------------------------------------------------------------------- +# System prompt +# --------------------------------------------------------------------------- + +AUTOCOMPLETE_SYSTEM_PROMPT = """You are a smart writing assistant that analyzes the user's screen to draft or complete text. + +You will receive a screenshot of the user's screen. Your PRIMARY source of truth is the screenshot itself — the visual context determines what to write. + +Your job: +1. Analyze the ENTIRE screenshot to understand what the user is working on (email thread, chat conversation, document, code editor, form, etc.). +2. Identify the text area where the user will type. +3. Generate the text the user most likely wants to write based on the visual context. + +You also have access to the user's knowledge base documents via filesystem tools. However: +- ONLY consult the knowledge base if the screenshot clearly involves a topic where your KB documents are DIRECTLY relevant (e.g., the user is writing about a specific project/topic that matches a document title). +- Do NOT explore documents just because they exist. Most autocomplete requests can be answered purely from the screenshot. +- If you do read a document, only incorporate information that is 100% relevant to what the user is typing RIGHT NOW. Do not add extra details, background, or tangential information from the KB. +- Keep your output SHORT — autocomplete should feel like a natural continuation, not an essay. + +Key behavior: +- If the text area is EMPTY, draft a concise response or message based on what you see on screen (e.g., reply to an email, respond to a chat message, continue a document). +- If the text area already has text, continue it naturally — typically just a sentence or two. + +Rules: +- Output ONLY the text to be inserted. No quotes, no explanations, no meta-commentary. +- Be CONCISE. Prefer a single paragraph or a few sentences. Autocomplete is a quick assist, not a full draft. +- Match the tone and formality of the surrounding context. +- If the screen shows code, write code. If it shows a casual chat, be casual. If it shows a formal email, be formal. +- Do NOT describe the screenshot or explain your reasoning. +- Do NOT cite or reference documents explicitly — just let the knowledge inform your writing naturally. +- If you cannot determine what to write, output nothing. + +## Filesystem Tools `ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep` + +All file paths must start with a `/`. +- ls: list files and directories at a given path. +- read_file: read a file from the filesystem. +- write_file: create a temporary file in the session (not persisted). +- edit_file: edit a file in the session (not persisted for /documents/ files). +- glob: find files matching a pattern (e.g., "**/*.xml"). +- grep: search for text within files. + +## When to Use Filesystem Tools + +BEFORE reaching for any tool, ask yourself: "Can I write a good completion purely from the screenshot?" If yes, just write it — do NOT explore the KB. + +Only use tools when: +- The user is clearly writing about a specific topic that likely has detailed information in their KB. +- You need a specific fact, name, number, or reference that the screenshot doesn't provide. + +When you do use tools, be surgical: +- Check the `ls` output first. If no document title looks relevant, stop — do not read files just to see what's there. +- If a title looks relevant, read only the `` (first ~20 lines) and jump to matched chunks. Do not read entire documents. +- Extract only the specific information you need and move on to generating the completion. + +## Reading Documents Efficiently + +Documents are formatted as XML. Each document contains: +- `` — title, type, URL, etc. +- `` — a table of every chunk with its **line range** and a + `matched="true"` flag for chunks that matched the search query. +- `` — the actual chunks in original document order. + +**Workflow**: read the first ~20 lines to see the ``, identify +chunks marked `matched="true"`, then use `read_file(path, offset=, +limit=)` to jump directly to those sections.""" + +APP_CONTEXT_BLOCK = """ + +The user is currently working in "{app_name}" (window: "{window_title}"). Use this to understand the type of application and adapt your tone and format accordingly.""" + + +def _build_autocomplete_system_prompt(app_name: str, window_title: str) -> str: + prompt = AUTOCOMPLETE_SYSTEM_PROMPT + if app_name: + prompt += APP_CONTEXT_BLOCK.format(app_name=app_name, window_title=window_title) + return prompt + + +# --------------------------------------------------------------------------- +# Pre-compute KB filesystem (runs in parallel with agent compilation) +# --------------------------------------------------------------------------- + + +class _KBResult: + """Container for pre-computed KB filesystem results.""" + + __slots__ = ("files", "ls_ai_msg", "ls_tool_msg") + + def __init__( + self, + files: dict[str, Any] | None = None, + ls_ai_msg: AIMessage | None = None, + ls_tool_msg: ToolMessage | None = None, + ) -> None: + self.files = files + self.ls_ai_msg = ls_ai_msg + self.ls_tool_msg = ls_tool_msg + + @property + def has_documents(self) -> bool: + return bool(self.files) + + +async def precompute_kb_filesystem( + search_space_id: int, + query: str, + top_k: int = KB_TOP_K, +) -> _KBResult: + """Search the KB and build the scoped filesystem outside the agent. + + This is designed to be called via ``asyncio.gather`` alongside agent + graph compilation so the two run concurrently. + """ + if not query: + return _KBResult() + + try: + search_results = await search_knowledge_base( + query=query, + search_space_id=search_space_id, + top_k=top_k, + ) + + if not search_results: + return _KBResult() + + new_files, _ = await build_scoped_filesystem( + documents=search_results, + search_space_id=search_space_id, + ) + + if not new_files: + return _KBResult() + + doc_paths = [ + p + for p, v in new_files.items() + if p.startswith("/documents/") and v is not None + ] + tool_call_id = f"auto_ls_{uuid.uuid4().hex[:12]}" + ai_msg = AIMessage( + content="", + tool_calls=[ + {"name": "ls", "args": {"path": "/documents"}, "id": tool_call_id} + ], + ) + tool_msg = ToolMessage( + content=str(doc_paths) if doc_paths else "No documents found.", + tool_call_id=tool_call_id, + ) + return _KBResult(files=new_files, ls_ai_msg=ai_msg, ls_tool_msg=tool_msg) + + except Exception: + logger.warning( + "KB pre-computation failed, proceeding without KB", exc_info=True + ) + return _KBResult() + + +# --------------------------------------------------------------------------- +# Filesystem middleware — no save_document, no persistence +# --------------------------------------------------------------------------- + + +class AutocompleteFilesystemMiddleware(SurfSenseFilesystemMiddleware): + """Filesystem middleware for autocomplete — read-only exploration only. + + Strips ``save_document`` (permanent KB persistence) and passes + ``search_space_id=None`` so ``write_file`` / ``edit_file`` stay ephemeral. + """ + + def __init__(self) -> None: + super().__init__(search_space_id=None, created_by_id=None) + self.tools = [t for t in self.tools if t.name != "save_document"] + + +# --------------------------------------------------------------------------- +# Agent factory +# --------------------------------------------------------------------------- + + +async def _compile_agent( + llm: BaseChatModel, + app_name: str, + window_title: str, +) -> Any: + """Compile the agent graph (CPU-bound, runs in a thread).""" + system_prompt = _build_autocomplete_system_prompt(app_name, window_title) + final_system_prompt = system_prompt + "\n\n" + BASE_AGENT_PROMPT + + middleware = [ + AutocompleteFilesystemMiddleware(), + PatchToolCallsMiddleware(), + AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"), + ] + + agent = await asyncio.to_thread( + create_agent, + llm, + system_prompt=final_system_prompt, + tools=[], + middleware=middleware, + ) + return agent.with_config({"recursion_limit": 200}) + + +async def create_autocomplete_agent( + llm: BaseChatModel, + *, + search_space_id: int, + kb_query: str, + app_name: str = "", + window_title: str = "", +) -> tuple[Any, _KBResult]: + """Create the autocomplete agent and pre-compute KB in parallel. + + Returns ``(agent, kb_result)`` so the caller can inject the pre-computed + filesystem into the agent's initial state without any middleware delay. + """ + agent, kb = await asyncio.gather( + _compile_agent(llm, app_name, window_title), + precompute_kb_filesystem(search_space_id, kb_query), + ) + return agent, kb + + +# --------------------------------------------------------------------------- +# Streaming helper +# --------------------------------------------------------------------------- + + +async def stream_autocomplete_agent( + agent: Any, + input_data: dict[str, Any], + streaming_service: VercelStreamingService, + *, + emit_message_start: bool = True, +) -> AsyncGenerator[str, None]: + """Stream agent events as Vercel SSE, with thinking steps for tool calls. + + When ``emit_message_start`` is False the caller has already sent the + ``message_start`` event (e.g. to show preparation steps before the agent + runs). + """ + thread_id = uuid.uuid4().hex + config = {"configurable": {"thread_id": thread_id}} + + current_text_id: str | None = None + active_tool_depth = 0 + thinking_step_counter = 0 + tool_step_ids: dict[str, str] = {} + step_titles: dict[str, str] = {} + completed_step_ids: set[str] = set() + last_active_step_id: str | None = None + + def next_thinking_step_id() -> str: + nonlocal thinking_step_counter + thinking_step_counter += 1 + return f"autocomplete-step-{thinking_step_counter}" + + def complete_current_step() -> str | None: + nonlocal last_active_step_id + if last_active_step_id and last_active_step_id not in completed_step_ids: + completed_step_ids.add(last_active_step_id) + title = step_titles.get(last_active_step_id, "Done") + event = streaming_service.format_thinking_step( + step_id=last_active_step_id, + title=title, + status="complete", + ) + last_active_step_id = None + return event + return None + + if emit_message_start: + yield streaming_service.format_message_start() + + # Emit an initial "Generating completion" step so the UI immediately + # shows activity once the agent starts its first LLM call. + gen_step_id = next_thinking_step_id() + last_active_step_id = gen_step_id + step_titles[gen_step_id] = "Generating completion" + yield streaming_service.format_thinking_step( + step_id=gen_step_id, + title="Generating completion", + status="in_progress", + ) + + try: + async for event in agent.astream_events( + input_data, config=config, version="v2" + ): + event_type = event.get("event", "") + + if event_type == "on_chat_model_stream": + if active_tool_depth > 0: + continue + if "surfsense:internal" in event.get("tags", []): + continue + chunk = event.get("data", {}).get("chunk") + if chunk and hasattr(chunk, "content"): + content = chunk.content + if content and isinstance(content, str): + if current_text_id is None: + step_event = complete_current_step() + if step_event: + yield step_event + current_text_id = streaming_service.generate_text_id() + yield streaming_service.format_text_start(current_text_id) + yield streaming_service.format_text_delta( + current_text_id, content + ) + + elif event_type == "on_tool_start": + active_tool_depth += 1 + tool_name = event.get("name", "unknown_tool") + run_id = event.get("run_id", "") + tool_input = event.get("data", {}).get("input", {}) + + if current_text_id is not None: + yield streaming_service.format_text_end(current_text_id) + current_text_id = None + + step_event = complete_current_step() + if step_event: + yield step_event + + tool_step_id = next_thinking_step_id() + tool_step_ids[run_id] = tool_step_id + last_active_step_id = tool_step_id + + title, items = _describe_tool_call(tool_name, tool_input) + step_titles[tool_step_id] = title + yield streaming_service.format_thinking_step( + step_id=tool_step_id, + title=title, + status="in_progress", + items=items, + ) + + elif event_type == "on_tool_end": + active_tool_depth = max(0, active_tool_depth - 1) + run_id = event.get("run_id", "") + step_id = tool_step_ids.pop(run_id, None) + if step_id and step_id not in completed_step_ids: + completed_step_ids.add(step_id) + title = step_titles.get(step_id, "Done") + yield streaming_service.format_thinking_step( + step_id=step_id, + title=title, + status="complete", + ) + if last_active_step_id == step_id: + last_active_step_id = None + + if current_text_id is not None: + yield streaming_service.format_text_end(current_text_id) + step_event = complete_current_step() + if step_event: + yield step_event + + yield streaming_service.format_finish() + yield streaming_service.format_done() + + except Exception as e: + logger.error(f"Autocomplete agent streaming error: {e}", exc_info=True) + if current_text_id is not None: + yield streaming_service.format_text_end(current_text_id) + yield streaming_service.format_error("Autocomplete failed. Please try again.") + yield streaming_service.format_done() + + +def _describe_tool_call(tool_name: str, tool_input: Any) -> tuple[str, list[str]]: + """Return a human-readable (title, items) for a tool call thinking step.""" + inp = tool_input if isinstance(tool_input, dict) else {} + if tool_name == "ls": + path = inp.get("path", "/") + return "Listing files", [path] + if tool_name == "read_file": + fp = inp.get("file_path", "") + display = fp if len(fp) <= 80 else "…" + fp[-77:] + return "Reading file", [display] + if tool_name == "write_file": + fp = inp.get("file_path", "") + display = fp if len(fp) <= 80 else "…" + fp[-77:] + return "Writing file", [display] + if tool_name == "edit_file": + fp = inp.get("file_path", "") + display = fp if len(fp) <= 80 else "…" + fp[-77:] + return "Editing file", [display] + if tool_name == "glob": + pat = inp.get("pattern", "") + base = inp.get("path", "/") + return "Searching files", [f"{pat} in {base}"] + if tool_name == "grep": + pat = inp.get("pattern", "") + path = inp.get("path", "") + display_pat = pat[:60] + ("…" if len(pat) > 60 else "") + return "Searching content", [ + f'"{display_pat}"' + (f" in {path}" if path else "") + ] + return f"Using {tool_name}", [] diff --git a/surfsense_backend/app/services/vision_autocomplete_service.py b/surfsense_backend/app/services/vision_autocomplete_service.py index 7e9408be7..c28962b31 100644 --- a/surfsense_backend/app/services/vision_autocomplete_service.py +++ b/surfsense_backend/app/services/vision_autocomplete_service.py @@ -1,149 +1,40 @@ +"""Vision autocomplete service — agent-based with scoped filesystem. + +Optimized pipeline: +1. Start the SSE stream immediately so the UI shows progress. +2. Derive a KB search query from window_title (no separate LLM call). +3. Run KB filesystem pre-computation and agent graph compilation in PARALLEL. +4. Inject pre-computed KB files as initial state and stream the agent. +""" + import logging from collections.abc import AsyncGenerator -from langchain_core.messages import HumanMessage, SystemMessage +from langchain_core.messages import HumanMessage from sqlalchemy.ext.asyncio import AsyncSession -from app.retriever.chunks_hybrid_search import ChucksHybridSearchRetriever +from app.agents.autocomplete import create_autocomplete_agent, stream_autocomplete_agent from app.services.llm_service import get_vision_llm from app.services.new_streaming_service import VercelStreamingService logger = logging.getLogger(__name__) -KB_TOP_K = 5 -KB_MAX_CHARS = 4000 - -EXTRACT_QUERY_PROMPT = """Look at this screenshot and describe in 1-2 short sentences what the user is working on and what topic they need to write about. Be specific about the subject matter. Output ONLY the description, nothing else.""" - -EXTRACT_QUERY_PROMPT_WITH_APP = """The user is currently in the application "{app_name}" with the window titled "{window_title}". - -Look at this screenshot and describe in 1-2 short sentences what the user is working on and what topic they need to write about. Be specific about the subject matter. Output ONLY the description, nothing else.""" - -VISION_SYSTEM_PROMPT = """You are a smart writing assistant that analyzes the user's screen to draft or complete text. - -You will receive a screenshot of the user's screen. Your job: -1. Analyze the ENTIRE screenshot to understand what the user is working on (email thread, chat conversation, document, code editor, form, etc.). -2. Identify the text area where the user will type. -3. Based on the full visual context, generate the text the user most likely wants to write. - -Key behavior: -- If the text area is EMPTY, draft a full response or message based on what you see on screen (e.g., reply to an email, respond to a chat message, continue a document). -- If the text area already has text, continue it naturally. - -Rules: -- Output ONLY the text to be inserted. No quotes, no explanations, no meta-commentary. -- Be concise but complete — a full thought, not a fragment. -- Match the tone and formality of the surrounding context. -- If the screen shows code, write code. If it shows a casual chat, be casual. If it shows a formal email, be formal. -- Do NOT describe the screenshot or explain your reasoning. -- If you cannot determine what to write, output nothing.""" - -APP_CONTEXT_BLOCK = """ - -The user is currently working in "{app_name}" (window: "{window_title}"). Use this to understand the type of application and adapt your tone and format accordingly.""" - -KB_CONTEXT_BLOCK = """ - -You also have access to the user's knowledge base documents below. Use them to write more accurate, informed, and contextually relevant text. Do NOT cite or reference the documents explicitly — just let the knowledge inform your writing naturally. - - -{kb_context} -""" +PREP_STEP_ID = "autocomplete-prep" -def _build_system_prompt(app_name: str, window_title: str, kb_context: str) -> str: - """Assemble the system prompt from optional context blocks.""" - prompt = VISION_SYSTEM_PROMPT - if app_name: - prompt += APP_CONTEXT_BLOCK.format(app_name=app_name, window_title=window_title) - if kb_context: - prompt += KB_CONTEXT_BLOCK.format(kb_context=kb_context) - return prompt +def _derive_kb_query(app_name: str, window_title: str) -> str: + parts = [p for p in (window_title, app_name) if p] + return " ".join(parts) def _is_vision_unsupported_error(e: Exception) -> bool: - """Check if an exception indicates the model doesn't support vision/images.""" msg = str(e).lower() return "content must be a string" in msg or "does not support image" in msg -async def _extract_query_from_screenshot( - llm, - screenshot_data_url: str, - app_name: str = "", - window_title: str = "", -) -> str | None: - """Ask the Vision LLM to describe what the user is working on. - - Raises vision-unsupported errors so the caller can return a - friendly message immediately instead of retrying with astream. - """ - if app_name: - prompt_text = EXTRACT_QUERY_PROMPT_WITH_APP.format( - app_name=app_name, - window_title=window_title, - ) - else: - prompt_text = EXTRACT_QUERY_PROMPT - - try: - response = await llm.ainvoke( - [ - HumanMessage( - content=[ - {"type": "text", "text": prompt_text}, - { - "type": "image_url", - "image_url": {"url": screenshot_data_url}, - }, - ] - ), - ] - ) - query = response.content.strip() if hasattr(response, "content") else "" - return query if query else None - except Exception as e: - if _is_vision_unsupported_error(e): - raise - logger.warning(f"Failed to extract query from screenshot: {e}") - return None - - -async def _search_knowledge_base( - session: AsyncSession, search_space_id: int, query: str -) -> str: - """Search the KB and return formatted context string.""" - try: - retriever = ChucksHybridSearchRetriever(session) - results = await retriever.hybrid_search( - query_text=query, - top_k=KB_TOP_K, - search_space_id=search_space_id, - ) - - if not results: - return "" - - parts: list[str] = [] - char_count = 0 - for doc in results: - title = doc.get("document", {}).get("title", "Untitled") - for chunk in doc.get("chunks", []): - content = chunk.get("content", "").strip() - if not content: - continue - entry = f"[{title}]\n{content}" - if char_count + len(entry) > KB_MAX_CHARS: - break - parts.append(entry) - char_count += len(entry) - if char_count >= KB_MAX_CHARS: - break - - return "\n\n---\n\n".join(parts) - except Exception as e: - logger.warning(f"KB search failed, proceeding without context: {e}") - return "" +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- async def stream_vision_autocomplete( @@ -154,13 +45,7 @@ async def stream_vision_autocomplete( app_name: str = "", window_title: str = "", ) -> AsyncGenerator[str, None]: - """Analyze a screenshot with the vision LLM and stream a text completion. - - Pipeline: - 1. Extract a search query from the screenshot (non-streaming) - 2. Search the knowledge base for relevant context - 3. Stream the final completion with screenshot + KB + app context - """ + """Analyze a screenshot with a vision-LLM agent and stream a text completion.""" streaming = VercelStreamingService() vision_error_msg = ( "The selected model does not support vision. " @@ -174,71 +59,100 @@ async def stream_vision_autocomplete( yield streaming.format_done() return - kb_context = "" + # Start SSE stream immediately so the UI has something to show + yield streaming.format_message_start() + + kb_query = _derive_kb_query(app_name, window_title) + + # Show a preparation step while KB search + agent compile run + yield streaming.format_thinking_step( + step_id=PREP_STEP_ID, + title="Searching knowledge base", + status="in_progress", + items=[kb_query] if kb_query else [], + ) + try: - query = await _extract_query_from_screenshot( + agent, kb = await create_autocomplete_agent( llm, - screenshot_data_url, + search_space_id=search_space_id, + kb_query=kb_query, app_name=app_name, window_title=window_title, ) except Exception as e: - logger.warning( - f"Vision autocomplete: selected model does not support vision: {e}" - ) - yield streaming.format_message_start() - yield streaming.format_error(vision_error_msg) + if _is_vision_unsupported_error(e): + logger.warning("Vision autocomplete: model does not support vision: %s", e) + yield streaming.format_error(vision_error_msg) + yield streaming.format_done() + return + logger.error("Failed to create autocomplete agent: %s", e, exc_info=True) + yield streaming.format_error("Autocomplete failed. Please try again.") yield streaming.format_done() return - if query: - kb_context = await _search_knowledge_base(session, search_space_id, query) + has_kb = kb.has_documents + doc_count = len(kb.files) if has_kb else 0 # type: ignore[arg-type] - system_prompt = _build_system_prompt(app_name, window_title, kb_context) + yield streaming.format_thinking_step( + step_id=PREP_STEP_ID, + title="Searching knowledge base", + status="complete", + items=[f"Found {doc_count} document{'s' if doc_count != 1 else ''}"] + if kb_query + else ["Skipped"], + ) - messages = [ - SystemMessage(content=system_prompt), - HumanMessage( - content=[ - { - "type": "text", - "text": "Analyze this screenshot. Understand the full context of what the user is working on, then generate the text they most likely want to write in the active text area.", - }, - { - "type": "image_url", - "image_url": {"url": screenshot_data_url}, - }, - ] - ), - ] + # Build agent input with pre-computed KB as initial state + if has_kb: + instruction = ( + "Analyze this screenshot, then explore the knowledge base documents " + "listed above — read the chunk index of any document whose title " + "looks relevant and check matched chunks for useful facts. " + "Finally, generate a concise autocomplete for the active text area, " + "enhanced with any relevant KB information you found." + ) + else: + instruction = ( + "Analyze this screenshot and generate a concise autocomplete " + "for the active text area based on what you see." + ) - text_started = False - text_id = "" + user_message = HumanMessage( + content=[ + {"type": "text", "text": instruction}, + {"type": "image_url", "image_url": {"url": screenshot_data_url}}, + ] + ) + + input_data: dict = {"messages": [user_message]} + + if has_kb: + input_data["files"] = kb.files + input_data["messages"] = [kb.ls_ai_msg, kb.ls_tool_msg, user_message] + logger.info( + "Autocomplete: injected %d KB files into agent initial state", doc_count + ) + else: + logger.info( + "Autocomplete: no KB documents found, proceeding with screenshot only" + ) + + # Stream the agent (message_start already sent above) try: - yield streaming.format_message_start() - text_id = streaming.generate_text_id() - yield streaming.format_text_start(text_id) - text_started = True - - async for chunk in llm.astream(messages): - token = chunk.content if hasattr(chunk, "content") else str(chunk) - if token: - yield streaming.format_text_delta(text_id, token) - - yield streaming.format_text_end(text_id) - yield streaming.format_finish() - yield streaming.format_done() - + async for sse in stream_autocomplete_agent( + agent, + input_data, + streaming, + emit_message_start=False, + ): + yield sse except Exception as e: - if text_started: - yield streaming.format_text_end(text_id) - if _is_vision_unsupported_error(e): - logger.warning( - f"Vision autocomplete: selected model does not support vision: {e}" - ) + logger.warning("Vision autocomplete: model does not support vision: %s", e) yield streaming.format_error(vision_error_msg) + yield streaming.format_done() else: - logger.error(f"Vision autocomplete streaming error: {e}", exc_info=True) + logger.error("Vision autocomplete streaming error: %s", e, exc_info=True) yield streaming.format_error("Autocomplete failed. Please try again.") - yield streaming.format_done() + yield streaming.format_done() diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml index 8e3f48b11..893aa77f9 100644 --- a/surfsense_backend/pyproject.toml +++ b/surfsense_backend/pyproject.toml @@ -46,8 +46,6 @@ dependencies = [ "redis>=5.2.1", "firecrawl-py>=4.9.0", "boto3>=1.35.0", - "litellm>=1.80.10", - "langchain-litellm>=0.3.5", "fake-useragent>=2.2.0", "trafilatura>=2.0.0", "fastapi-users[oauth,sqlalchemy]>=15.0.3", @@ -75,6 +73,8 @@ dependencies = [ "langchain-community>=0.4.1", "deepagents>=0.4.12", "stripe>=15.0.0", + "litellm>=1.83.0", + "langchain-litellm>=0.6.4", ] [dependency-groups] diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock index 8de78705d..c35bbf7d7 100644 --- a/surfsense_backend/uv.lock +++ b/surfsense_backend/uv.lock @@ -62,7 +62,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.13.3" +version = "3.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -73,76 +73,76 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556 } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732 }, - { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293 }, - { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533 }, - { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839 }, - { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932 }, - { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906 }, - { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020 }, - { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181 }, - { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794 }, - { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900 }, - { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239 }, - { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527 }, - { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489 }, - { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852 }, - { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379 }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253 }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407 }, - { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190 }, - { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783 }, - { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704 }, - { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652 }, - { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014 }, - { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777 }, - { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276 }, - { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131 }, - { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863 }, - { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793 }, - { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676 }, - { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217 }, - { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303 }, - { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673 }, - { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120 }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383 }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899 }, - { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238 }, - { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292 }, - { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021 }, - { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263 }, - { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107 }, - { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196 }, - { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591 }, - { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277 }, - { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575 }, - { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455 }, - { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417 }, - { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968 }, - { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690 }, - { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390 }, - { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188 }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126 }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128 }, - { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512 }, - { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444 }, - { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798 }, - { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835 }, - { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486 }, - { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951 }, - { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001 }, - { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246 }, - { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131 }, - { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196 }, - { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841 }, - { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193 }, - { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979 }, - { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193 }, - { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801 }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523 }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694 }, + { url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876 }, + { url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557 }, + { url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258 }, + { url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199 }, + { url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013 }, + { url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501 }, + { url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981 }, + { url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934 }, + { url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671 }, + { url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219 }, + { url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049 }, + { url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557 }, + { url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931 }, + { url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125 }, + { url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427 }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534 }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446 }, + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930 }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927 }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141 }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476 }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507 }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465 }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523 }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113 }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351 }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205 }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618 }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185 }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311 }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147 }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356 }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637 }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896 }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721 }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663 }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094 }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701 }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360 }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023 }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795 }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405 }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082 }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346 }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891 }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113 }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088 }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976 }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444 }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128 }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029 }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758 }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883 }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668 }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461 }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661 }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800 }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382 }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724 }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027 }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644 }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630 }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403 }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924 }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119 }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072 }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819 }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441 }, ] [[package]] @@ -1023,14 +1023,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, ] [[package]] @@ -2984,14 +2984,14 @@ wheels = [ [[package]] name = "importlib-metadata" -version = "8.7.1" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865 }, + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 }, ] [[package]] @@ -3222,7 +3222,7 @@ wheels = [ [[package]] name = "jsonschema" -version = "4.26.0" +version = "4.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -3230,9 +3230,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 }, ] [[package]] @@ -3533,7 +3533,7 @@ wheels = [ [[package]] name = "langchain-litellm" -version = "0.6.2" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, @@ -3541,9 +3541,9 @@ dependencies = [ { name = "langchain-core" }, { name = "litellm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ee/6f/ba0490ec0fbc9d97cd9433749455fb4b5fbec3852bcbe113a0278ec1d32d/langchain_litellm-0.6.2.tar.gz", hash = "sha256:93372df7c3f1802358746e2c0a94012d8c27d9f9b57b769b23f6af2264bbaabb", size = 332878 } +sdist = { url = "https://files.pythonhosted.org/packages/68/37/ccc1f284a42900ca5b267a50da8e50145e9f264b32ee955ce91aa360d188/langchain_litellm-0.6.4.tar.gz", hash = "sha256:663281db392b3de1f07f891d0f80f9d4b26c0f0d2abbf854ef9b186d99c309ee", size = 339457 } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/14/ad857a3f56fa4ea0879ac9d6ee5248c883663d0bad94bf8741e1ab6ab200/langchain_litellm-0.6.2-py3-none-any.whl", hash = "sha256:98af79dbcdea4b492e9601351bc5fd15fdd368e021183b8540f0d0b6b6b1589c", size = 24865 }, + { url = "https://files.pythonhosted.org/packages/43/e8/25c50bbad7a05106c7af65557e165d6cb6159c90854dae61de59debe735d/langchain_litellm-0.6.4-py3-none-any.whl", hash = "sha256:60f4e37be1a47dc88f94fac7085675ef8fa04bba92f48735792d82f492120744", size = 26360 }, ] [[package]] @@ -3709,7 +3709,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.82.6" +version = "1.83.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -3725,9 +3725,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/29/75/1c537aa458426a9127a92bc2273787b2f987f4e5044e21f01f2eed5244fd/litellm-1.82.6.tar.gz", hash = "sha256:2aa1c2da21fe940c33613aa447119674a3ad4d2ad5eb064e4d5ce5ee42420136", size = 17414147 } +sdist = { url = "https://files.pythonhosted.org/packages/03/c4/30469c06ae7437a4406bc11e3c433cfd380a6771068cca15ea918dcd158f/litellm-1.83.4.tar.gz", hash = "sha256:6458d2030a41229460b321adee00517a91dbd8e63213cc953d355cb41d16f2d4", size = 17733899 } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595 }, + { url = "https://files.pythonhosted.org/packages/b8/bd/df19d3f8f6654535ee343a341fd921f81c411abf601a53e3eaef58129b02/litellm-1.83.4-py3-none-any.whl", hash = "sha256:17d7b4d48d47aca988ea4f762ddda5e7bd72cda3270192b22813d0330869d7b4", size = 16015555 }, ] [[package]] @@ -6766,11 +6766,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, ] [[package]] @@ -8049,12 +8049,12 @@ requires-dist = [ { name = "langchain", specifier = ">=1.2.13" }, { name = "langchain-community", specifier = ">=0.4.1" }, { name = "langchain-daytona", specifier = ">=0.0.2" }, - { name = "langchain-litellm", specifier = ">=0.3.5" }, + { name = "langchain-litellm", specifier = ">=0.6.4" }, { name = "langchain-unstructured", specifier = ">=1.0.1" }, { name = "langgraph", specifier = ">=1.1.3" }, { name = "langgraph-checkpoint-postgres", specifier = ">=3.0.2" }, { name = "linkup-sdk", specifier = ">=0.2.4" }, - { name = "litellm", specifier = ">=1.80.10" }, + { name = "litellm", specifier = ">=1.83.0" }, { name = "llama-cloud-services", specifier = ">=0.6.25" }, { name = "markdown", specifier = ">=3.7" }, { name = "markdownify", specifier = ">=0.14.1" }, diff --git a/surfsense_desktop/electron-builder.yml b/surfsense_desktop/electron-builder.yml index be5e07c63..2c46c827a 100644 --- a/surfsense_desktop/electron-builder.yml +++ b/surfsense_desktop/electron-builder.yml @@ -19,6 +19,9 @@ files: - "!scripts" - "!release" extraResources: + - from: assets/ + to: assets/ + filter: ["*.ico", "*.png", "*.icns"] - from: ../surfsense_web/.next/standalone/surfsense_web/ to: standalone/ filter: @@ -58,7 +61,7 @@ win: icon: assets/icon.ico target: - target: nsis - arch: [x64, arm64] + arch: [x64] nsis: oneClick: false perMachine: false diff --git a/surfsense_desktop/package.json b/surfsense_desktop/package.json index 58c053c04..74f6274cb 100644 --- a/surfsense_desktop/package.json +++ b/surfsense_desktop/package.json @@ -4,7 +4,7 @@ "description": "SurfSense Desktop App", "main": "dist/main.js", "scripts": { - "dev": "concurrently -k \"pnpm --dir ../surfsense_web dev\" \"wait-on http://localhost:3000 && electron .\"", + "dev": "pnpm build && concurrently -k \"pnpm --dir ../surfsense_web dev\" \"wait-on http://localhost:3000 && electron .\"", "build": "node scripts/build-electron.mjs", "pack:dir": "pnpm build && electron-builder --dir --config electron-builder.yml", "dist": "pnpm build && electron-builder --config electron-builder.yml", diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 2a50de75f..39e75f046 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -32,4 +32,13 @@ export const IPC_CHANNELS = { FOLDER_SYNC_ACK_EVENTS: 'folder-sync:ack-events', BROWSE_FILES: 'browse:files', READ_LOCAL_FILES: 'browse:read-local-files', + // Auth token sync across windows + GET_AUTH_TOKENS: 'auth:get-tokens', + SET_AUTH_TOKENS: 'auth:set-tokens', + // Keyboard shortcut configuration + GET_SHORTCUTS: 'shortcuts:get', + SET_SHORTCUTS: 'shortcuts:set', + // Active search space + GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active', + SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active', } as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index de7cdb659..200fa75bd 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -20,6 +20,13 @@ import { browseFiles, readLocalFiles, } from '../modules/folder-watcher'; +import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts'; +import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space'; +import { reregisterQuickAsk } from '../modules/quick-ask'; +import { reregisterAutocomplete } from '../modules/autocomplete'; +import { reregisterGeneralAssist } from '../modules/tray'; + +let authTokens: { bearer: string; refresh: string } | null = null; export function registerIpcHandlers(): void { ipcMain.on(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url: string) => { @@ -89,4 +96,28 @@ export function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.READ_LOCAL_FILES, (_event, paths: string[]) => readLocalFiles(paths) ); + + ipcMain.handle(IPC_CHANNELS.SET_AUTH_TOKENS, (_event, tokens: { bearer: string; refresh: string }) => { + authTokens = tokens; + }); + + ipcMain.handle(IPC_CHANNELS.GET_AUTH_TOKENS, () => { + return authTokens; + }); + + ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts()); + + ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE, () => getActiveSearchSpaceId()); + + ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, (_event, id: string) => + setActiveSearchSpaceId(id) + ); + + ipcMain.handle(IPC_CHANNELS.SET_SHORTCUTS, async (_event, config: Partial) => { + const updated = await setShortcuts(config); + if (config.generalAssist) await reregisterGeneralAssist(); + if (config.quickAsk) await reregisterQuickAsk(); + if (config.autocomplete) await reregisterAutocomplete(); + return updated; + }); } diff --git a/surfsense_desktop/src/main.ts b/surfsense_desktop/src/main.ts index 7ef0ad5be..95b0359c8 100644 --- a/surfsense_desktop/src/main.ts +++ b/surfsense_desktop/src/main.ts @@ -1,7 +1,9 @@ import { app, BrowserWindow } from 'electron'; + +let isQuitting = false; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { startNextServer } from './modules/server'; -import { createMainWindow } from './modules/window'; +import { createMainWindow, getMainWindow } from './modules/window'; import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupAutoUpdater } from './modules/auto-updater'; import { setupMenu } from './modules/menu'; @@ -9,6 +11,7 @@ import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask'; import { registerAutocomplete, unregisterAutocomplete } from './modules/autocomplete'; import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder-watcher'; import { registerIpcHandlers } from './ipc/handlers'; +import { createTray, destroyTray } from './modules/tray'; registerGlobalErrorHandlers(); @@ -28,29 +31,48 @@ app.whenReady().then(async () => { return; } - createMainWindow('/dashboard'); - registerQuickAsk(); - registerAutocomplete(); + await createTray(); + + const win = createMainWindow('/dashboard'); + + // Minimize to tray instead of closing the app + win.on('close', (e) => { + if (!isQuitting) { + e.preventDefault(); + win.hide(); + } + }); + + await registerQuickAsk(); + await registerAutocomplete(); registerFolderWatcher(); setupAutoUpdater(); handlePendingDeepLink(); app.on('activate', () => { - if (BrowserWindow.getAllWindows().length === 0) { + const mw = getMainWindow(); + if (!mw || mw.isDestroyed()) { createMainWindow('/dashboard'); + } else { + mw.show(); + mw.focus(); } }); }); +// Keep running in the background — the tray "Quit" calls app.exit() app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit(); - } + // Do nothing: the app stays alive in the tray +}); + +app.on('before-quit', () => { + isQuitting = true; }); app.on('will-quit', () => { unregisterQuickAsk(); unregisterAutocomplete(); unregisterFolderWatcher(); + destroyTray(); }); diff --git a/surfsense_desktop/src/modules/active-search-space.ts b/surfsense_desktop/src/modules/active-search-space.ts new file mode 100644 index 000000000..e5f55c8f4 --- /dev/null +++ b/surfsense_desktop/src/modules/active-search-space.ts @@ -0,0 +1,24 @@ +const STORE_KEY = 'activeSearchSpaceId'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let store: any = null; + +async function getStore() { + if (!store) { + const { default: Store } = await import('electron-store'); + store = new Store({ + name: 'active-search-space', + defaults: { [STORE_KEY]: null as string | null }, + }); + } + return store; +} + +export async function getActiveSearchSpaceId(): Promise { + const s = await getStore(); + return (s.get(STORE_KEY) as string | null) ?? null; +} + +export async function setActiveSearchSpaceId(id: string): Promise { + const s = await getStore(); + s.set(STORE_KEY, id); +} diff --git a/surfsense_desktop/src/modules/autocomplete/index.ts b/surfsense_desktop/src/modules/autocomplete/index.ts index 01a4cf913..cb09a42e1 100644 --- a/surfsense_desktop/src/modules/autocomplete/index.ts +++ b/surfsense_desktop/src/modules/autocomplete/index.ts @@ -2,16 +2,15 @@ import { clipboard, globalShortcut, ipcMain, screen } from 'electron'; import { IPC_CHANNELS } from '../../ipc/channels'; import { getFrontmostApp, getWindowTitle, hasAccessibilityPermission, simulatePaste } from '../platform'; import { hasScreenRecordingPermission, requestAccessibility, requestScreenRecording } from '../permissions'; -import { getMainWindow } from '../window'; import { captureScreen } from './screenshot'; import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window'; +import { getShortcuts } from '../shortcuts'; +import { getActiveSearchSpaceId } from '../active-search-space'; -const SHORTCUT = 'CommandOrControl+Shift+Space'; - +let currentShortcut = ''; let autocompleteEnabled = true; let savedClipboard = ''; let sourceApp = ''; -let lastSearchSpaceId: string | null = null; function isSurfSenseWindow(): boolean { const app = getFrontmostApp(); @@ -37,21 +36,11 @@ async function triggerAutocomplete(): Promise { return; } - const mainWin = getMainWindow(); - if (mainWin && !mainWin.isDestroyed()) { - const mainUrl = mainWin.webContents.getURL(); - const match = mainUrl.match(/\/dashboard\/(\d+)/); - if (match) { - lastSearchSpaceId = match[1]; - } - } - - if (!lastSearchSpaceId) { - console.warn('[autocomplete] No active search space. Open a search space first.'); + const searchSpaceId = await getActiveSearchSpaceId(); + if (!searchSpaceId) { + console.warn('[autocomplete] No active search space. Select a search space first.'); return; } - - const searchSpaceId = lastSearchSpaceId; const cursor = screen.getCursorScreenPoint(); const win = createSuggestionWindow(cursor.x, cursor.y); @@ -91,7 +80,12 @@ async function acceptAndInject(text: string): Promise { } } +let ipcRegistered = false; + function registerIpcHandlers(): void { + if (ipcRegistered) return; + ipcRegistered = true; + ipcMain.handle(IPC_CHANNELS.ACCEPT_SUGGESTION, async (_event, text: string) => { await acceptAndInject(text); }); @@ -107,26 +101,39 @@ function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.GET_AUTOCOMPLETE_ENABLED, () => autocompleteEnabled); } -export function registerAutocomplete(): void { - registerIpcHandlers(); +function autocompleteHandler(): void { + const sw = getSuggestionWindow(); + if (sw && !sw.isDestroyed()) { + destroySuggestion(); + return; + } + triggerAutocomplete(); +} - const ok = globalShortcut.register(SHORTCUT, () => { - const sw = getSuggestionWindow(); - if (sw && !sw.isDestroyed()) { - destroySuggestion(); - return; - } - triggerAutocomplete(); - }); +async function registerShortcut(): Promise { + const shortcuts = await getShortcuts(); + currentShortcut = shortcuts.autocomplete; + + const ok = globalShortcut.register(currentShortcut, autocompleteHandler); if (!ok) { - console.error(`[autocomplete] Failed to register shortcut ${SHORTCUT}`); + console.error(`[autocomplete] Failed to register shortcut ${currentShortcut}`); } else { - console.log(`[autocomplete] Registered shortcut ${SHORTCUT}`); + console.log(`[autocomplete] Registered shortcut ${currentShortcut}`); } } +export async function registerAutocomplete(): Promise { + registerIpcHandlers(); + await registerShortcut(); +} + export function unregisterAutocomplete(): void { - globalShortcut.unregister(SHORTCUT); + if (currentShortcut) globalShortcut.unregister(currentShortcut); destroySuggestion(); } + +export async function reregisterAutocomplete(): Promise { + unregisterAutocomplete(); + await registerShortcut(); +} diff --git a/surfsense_desktop/src/modules/platform.ts b/surfsense_desktop/src/modules/platform.ts index 122e2efed..2b4d1f4a1 100644 --- a/surfsense_desktop/src/modules/platform.ts +++ b/surfsense_desktop/src/modules/platform.ts @@ -1,16 +1,20 @@ import { execSync } from 'child_process'; import { systemPreferences } from 'electron'; +const EXEC_OPTS = { windowsHide: true } as const; + export function getFrontmostApp(): string { try { if (process.platform === 'darwin') { return execSync( - 'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\'' + 'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\'', + EXEC_OPTS, ).toString().trim(); } if (process.platform === 'win32') { return execSync( - 'powershell -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"' + 'powershell -NoProfile -NonInteractive -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"', + EXEC_OPTS, ).toString().trim(); } } catch { @@ -21,9 +25,23 @@ export function getFrontmostApp(): string { export function simulatePaste(): void { if (process.platform === 'darwin') { - execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\''); + execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\'', EXEC_OPTS); } else if (process.platform === 'win32') { - execSync('powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"'); + execSync('powershell -NoProfile -NonInteractive -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"', EXEC_OPTS); + } +} + +export function simulateCopy(): boolean { + try { + if (process.platform === 'darwin') { + execSync('osascript -e \'tell application "System Events" to keystroke "c" using command down\'', EXEC_OPTS); + } else if (process.platform === 'win32') { + execSync('powershell -NoProfile -NonInteractive -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^c\')"', EXEC_OPTS); + } + return true; + } catch (err) { + console.error('[simulateCopy] Failed:', err); + return false; } } @@ -36,12 +54,14 @@ export function getWindowTitle(): string { try { if (process.platform === 'darwin') { return execSync( - 'osascript -e \'tell application "System Events" to get title of front window of first application process whose frontmost is true\'' + 'osascript -e \'tell application "System Events" to get title of front window of first application process whose frontmost is true\'', + EXEC_OPTS, ).toString().trim(); } if (process.platform === 'win32') { return execSync( - 'powershell -command "(Get-Process | Where-Object { $_.MainWindowHandle -eq (Add-Type -MemberDefinition \'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();\' -Name W -PassThru)::GetForegroundWindow() }).MainWindowTitle"' + 'powershell -NoProfile -NonInteractive -command "(Get-Process | Where-Object { $_.MainWindowHandle -eq (Add-Type -MemberDefinition \'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();\' -Name W -PassThru)::GetForegroundWindow() }).MainWindowTitle"', + EXEC_OPTS, ).toString().trim(); } } catch { diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 52bfc6054..d5a2a9c2e 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,13 +1,16 @@ import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron'; import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; -import { checkAccessibilityPermission, getFrontmostApp, simulatePaste } from './platform'; +import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform'; import { getServerPort } from './server'; +import { getShortcuts } from './shortcuts'; +import { getActiveSearchSpaceId } from './active-search-space'; -const SHORTCUT = 'CommandOrControl+Option+S'; +let currentShortcut = ''; let quickAskWindow: BrowserWindow | null = null; let pendingText = ''; let pendingMode = ''; +let pendingSearchSpaceId: string | null = null; let sourceApp = ''; let savedClipboard = ''; @@ -52,7 +55,9 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { skipTaskbar: true, }); - quickAskWindow.loadURL(`http://localhost:${getServerPort()}/dashboard`); + const spaceId = pendingSearchSpaceId; + const route = spaceId ? `/dashboard/${spaceId}/new-chat` : '/dashboard'; + quickAskWindow.loadURL(`http://localhost:${getServerPort()}${route}`); quickAskWindow.once('ready-to-show', () => { quickAskWindow?.show(); @@ -77,29 +82,53 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { return quickAskWindow; } -export function registerQuickAsk(): void { - const ok = globalShortcut.register(SHORTCUT, () => { - if (quickAskWindow && !quickAskWindow.isDestroyed()) { - destroyQuickAsk(); - return; - } +async function openQuickAsk(text: string): Promise { + pendingText = text; + pendingSearchSpaceId = await getActiveSearchSpaceId(); + const cursor = screen.getCursorScreenPoint(); + const pos = clampToScreen(cursor.x, cursor.y, 450, 750); + createQuickAskWindow(pos.x, pos.y); +} - sourceApp = getFrontmostApp(); - savedClipboard = clipboard.readText(); +async function quickAskHandler(): Promise { + console.log('[quick-ask] Handler triggered'); - const text = savedClipboard.trim(); - if (!text) return; - - pendingText = text; - const cursor = screen.getCursorScreenPoint(); - const pos = clampToScreen(cursor.x, cursor.y, 450, 750); - createQuickAskWindow(pos.x, pos.y); - }); - - if (!ok) { - console.log(`Quick-ask: failed to register ${SHORTCUT}`); + if (quickAskWindow && !quickAskWindow.isDestroyed()) { + console.log('[quick-ask] Window already open, closing'); + destroyQuickAsk(); + return; } + if (!checkAccessibilityPermission()) { + console.log('[quick-ask] Accessibility permission denied'); + return; + } + + savedClipboard = clipboard.readText(); + console.log('[quick-ask] Saved clipboard length:', savedClipboard.length); + + const copyOk = simulateCopy(); + console.log('[quick-ask] simulateCopy result:', copyOk); + + await new Promise((r) => setTimeout(r, 300)); + + const afterCopy = clipboard.readText(); + const selected = afterCopy.trim(); + console.log('[quick-ask] Clipboard after copy length:', afterCopy.length, 'changed:', afterCopy !== savedClipboard); + + const text = selected || savedClipboard.trim(); + + sourceApp = getFrontmostApp(); + console.log('[quick-ask] Source app:', sourceApp, '| Opening Quick Assist with', text.length, 'chars', selected ? '(selected)' : text ? '(clipboard fallback)' : '(empty)'); + openQuickAsk(text); +} + +let ipcRegistered = false; + +function registerIpcHandlers(): void { + if (ipcRegistered) return; + ipcRegistered = true; + ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => { const text = pendingText; pendingText = ''; @@ -136,6 +165,24 @@ export function registerQuickAsk(): void { }); } -export function unregisterQuickAsk(): void { - globalShortcut.unregister(SHORTCUT); +async function registerShortcut(): Promise { + const shortcuts = await getShortcuts(); + currentShortcut = shortcuts.quickAsk; + + const ok = globalShortcut.register(currentShortcut, () => { quickAskHandler(); }); + console.log(`[quick-ask] Register ${currentShortcut}: ${ok ? 'OK' : 'FAILED'}`); +} + +export async function registerQuickAsk(): Promise { + registerIpcHandlers(); + await registerShortcut(); +} + +export function unregisterQuickAsk(): void { + if (currentShortcut) globalShortcut.unregister(currentShortcut); +} + +export async function reregisterQuickAsk(): Promise { + unregisterQuickAsk(); + await registerShortcut(); } diff --git a/surfsense_desktop/src/modules/shortcuts.ts b/surfsense_desktop/src/modules/shortcuts.ts new file mode 100644 index 000000000..6948a005e --- /dev/null +++ b/surfsense_desktop/src/modules/shortcuts.ts @@ -0,0 +1,44 @@ +export interface ShortcutConfig { + generalAssist: string; + quickAsk: string; + autocomplete: string; +} + +const DEFAULTS: ShortcutConfig = { + generalAssist: 'CommandOrControl+Shift+S', + quickAsk: 'CommandOrControl+Alt+S', + autocomplete: 'CommandOrControl+Shift+Space', +}; + +const STORE_KEY = 'shortcuts'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- lazily imported ESM module; matches folder-watcher.ts pattern +let store: any = null; + +async function getStore() { + if (!store) { + const { default: Store } = await import('electron-store'); + store = new Store({ + name: 'keyboard-shortcuts', + defaults: { [STORE_KEY]: DEFAULTS }, + }); + } + return store; +} + +export async function getShortcuts(): Promise { + const s = await getStore(); + const stored = s.get(STORE_KEY) as Partial | undefined; + return { ...DEFAULTS, ...stored }; +} + +export async function setShortcuts(config: Partial): Promise { + const s = await getStore(); + const current = (s.get(STORE_KEY) as ShortcutConfig) ?? DEFAULTS; + const merged = { ...current, ...config }; + s.set(STORE_KEY, merged); + return merged; +} + +export function getDefaults(): ShortcutConfig { + return { ...DEFAULTS }; +} diff --git a/surfsense_desktop/src/modules/tray.ts b/surfsense_desktop/src/modules/tray.ts new file mode 100644 index 000000000..1749145a1 --- /dev/null +++ b/surfsense_desktop/src/modules/tray.ts @@ -0,0 +1,77 @@ +import { app, globalShortcut, Menu, nativeImage, Tray } from 'electron'; +import path from 'path'; +import { getMainWindow, createMainWindow } from './window'; +import { getShortcuts } from './shortcuts'; + +let tray: Tray | null = null; +let currentShortcut: string | null = null; + +function getTrayIcon(): nativeImage { + const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png'; + const iconPath = app.isPackaged + ? path.join(process.resourcesPath, 'assets', iconName) + : path.join(__dirname, '..', 'assets', iconName); + const img = nativeImage.createFromPath(iconPath); + return img.resize({ width: 16, height: 16 }); +} + +function showMainWindow(): void { + let win = getMainWindow(); + if (!win || win.isDestroyed()) { + win = createMainWindow('/dashboard'); + } else { + win.show(); + win.focus(); + } +} + +function registerShortcut(accelerator: string): void { + if (currentShortcut) { + globalShortcut.unregister(currentShortcut); + currentShortcut = null; + } + if (!accelerator) return; + try { + const ok = globalShortcut.register(accelerator, showMainWindow); + if (ok) { + currentShortcut = accelerator; + } else { + console.warn(`[tray] Failed to register General Assist shortcut: ${accelerator}`); + } + } catch (err) { + console.error(`[tray] Error registering General Assist shortcut:`, err); + } +} + +export async function createTray(): Promise { + if (tray) return; + + tray = new Tray(getTrayIcon()); + tray.setToolTip('SurfSense'); + + const contextMenu = Menu.buildFromTemplate([ + { label: 'Open SurfSense', click: showMainWindow }, + { type: 'separator' }, + { label: 'Quit', click: () => { app.exit(0); } }, + ]); + + tray.setContextMenu(contextMenu); + tray.on('double-click', showMainWindow); + + const shortcuts = await getShortcuts(); + registerShortcut(shortcuts.generalAssist); +} + +export async function reregisterGeneralAssist(): Promise { + const shortcuts = await getShortcuts(); + registerShortcut(shortcuts.generalAssist); +} + +export function destroyTray(): void { + if (currentShortcut) { + globalShortcut.unregister(currentShortcut); + currentShortcut = null; + } + tray?.destroy(); + tray = null; +} diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts index 7a77773d8..9cd216501 100644 --- a/surfsense_desktop/src/modules/window.ts +++ b/surfsense_desktop/src/modules/window.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, shell, session } from 'electron'; import path from 'path'; import { showErrorDialog } from './errors'; import { getServerPort } from './server'; +import { setActiveSearchSpaceId } from './active-search-space'; const isDev = !app.isPackaged; const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; @@ -55,6 +56,16 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { showErrorDialog('Page failed to load', new Error(`${errorDescription} (${errorCode})\n${validatedURL}`)); }); + // Auto-sync active search space from URL navigation + const syncSearchSpace = (url: string) => { + const match = url.match(/\/dashboard\/(\d+)/); + if (match) { + setActiveSearchSpaceId(match[1]); + } + }; + mainWindow.webContents.on('did-navigate', (_event, url) => syncSearchSpace(url)); + mainWindow.webContents.on('did-navigate-in-page', (_event, url) => syncSearchSpace(url)); + if (isDev) { mainWindow.webContents.openDevTools(); } diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 6a9190693..4d9537c91 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -68,4 +68,19 @@ contextBridge.exposeInMainWorld('electronAPI', { // Browse files via native dialog browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES), readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths), + + // Auth token sync across windows + getAuthTokens: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTH_TOKENS), + setAuthTokens: (bearer: string, refresh: string) => + ipcRenderer.invoke(IPC_CHANNELS.SET_AUTH_TOKENS, { bearer, refresh }), + + // Keyboard shortcut configuration + getShortcuts: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SHORTCUTS), + setShortcuts: (config: Record) => + ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config), + + // Active search space + getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE), + setActiveSearchSpace: (id: string) => + ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id), }); diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 60b8aef12..16af9ac6b 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -19,6 +19,7 @@ import { OnboardingTour } from "@/components/onboarding-tour"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useFolderSync } from "@/hooks/use-folder-sync"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; +import { useElectronAPI } from "@/hooks/use-platform"; export function DashboardClientLayout({ children, @@ -139,6 +140,8 @@ export function DashboardClientLayout({ refetchPreferences, ]); + const electronAPI = useElectronAPI(); + useEffect(() => { const activeSeacrhSpaceId = typeof search_space_id === "string" @@ -148,7 +151,16 @@ export function DashboardClientLayout({ : ""; if (!activeSeacrhSpaceId) return; setActiveSearchSpaceIdState(activeSeacrhSpaceId); - }, [search_space_id, setActiveSearchSpaceIdState]); + + // Sync to Electron store if stored value is null (first navigation) + if (electronAPI?.setActiveSearchSpace) { + electronAPI.getActiveSearchSpace?.().then((stored) => { + if (!stored) { + electronAPI.setActiveSearchSpace!(activeSeacrhSpaceId); + } + }).catch(() => {}); + } + }, [search_space_id, setActiveSearchSpaceIdState, electronAPI]); // Determine if we should show loading const shouldShowLoading = diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx index 957ae9dae..596ed3e8b 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/DesktopContent.tsx @@ -1,30 +1,65 @@ "use client"; +import { BrainCog, Rocket, Zap } from "lucide-react"; import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; +import { useElectronAPI } from "@/hooks/use-platform"; +import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; +import type { SearchSpace } from "@/contracts/types/search-space.types"; export function DesktopContent() { - const [isElectron, setIsElectron] = useState(false); + const api = useElectronAPI(); const [loading, setLoading] = useState(true); const [enabled, setEnabled] = useState(true); + const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); + const [shortcutsLoaded, setShortcutsLoaded] = useState(false); + + const [searchSpaces, setSearchSpaces] = useState([]); + const [activeSpaceId, setActiveSpaceId] = useState(null); + useEffect(() => { - if (!window.electronAPI) { + if (!api) { setLoading(false); + setShortcutsLoaded(true); return; } - setIsElectron(true); - window.electronAPI.getAutocompleteEnabled().then((val) => { - setEnabled(val); - setLoading(false); - }); - }, []); + let mounted = true; - if (!isElectron) { + Promise.all([ + api.getAutocompleteEnabled(), + api.getShortcuts?.() ?? Promise.resolve(null), + api.getActiveSearchSpace?.() ?? Promise.resolve(null), + searchSpacesApiService.getSearchSpaces(), + ]) + .then(([autoEnabled, config, spaceId, spaces]) => { + if (!mounted) return; + setEnabled(autoEnabled); + if (config) setShortcuts(config); + setActiveSpaceId(spaceId); + if (spaces) setSearchSpaces(spaces); + setLoading(false); + setShortcutsLoaded(true); + }) + .catch(() => { + if (!mounted) return; + setLoading(false); + setShortcutsLoaded(true); + }); + + return () => { + mounted = false; + }; + }, [api]); + + if (!api) { return (

@@ -44,14 +79,114 @@ export function DesktopContent() { const handleToggle = async (checked: boolean) => { setEnabled(checked); - await window.electronAPI!.setAutocompleteEnabled(checked); + await api.setAutocompleteEnabled(checked); + }; + + const updateShortcut = (key: "generalAssist" | "quickAsk" | "autocomplete", accelerator: string) => { + setShortcuts((prev) => { + const updated = { ...prev, [key]: accelerator }; + api.setShortcuts?.({ [key]: accelerator }).catch(() => { + toast.error("Failed to update shortcut"); + }); + return updated; + }); + toast.success("Shortcut updated"); + }; + + const resetShortcut = (key: "generalAssist" | "quickAsk" | "autocomplete") => { + updateShortcut(key, DEFAULT_SHORTCUTS[key]); + }; + + const handleSearchSpaceChange = (value: string) => { + setActiveSpaceId(value); + api.setActiveSearchSpace?.(value); + toast.success("Default search space updated"); }; return (

+ {/* Default Search Space */} - Autocomplete + Default Search Space + + Choose which search space General Assist, Quick Assist, and Extreme Assist operate against. + + + + {searchSpaces.length > 0 ? ( + + ) : ( +

No search spaces found. Create one first.

+ )} +
+
+ + {/* Keyboard Shortcuts */} + + + Keyboard Shortcuts + + Customize the global keyboard shortcuts for desktop features. + + + + {shortcutsLoaded ? ( +
+ updateShortcut("generalAssist", accel)} + onReset={() => resetShortcut("generalAssist")} + defaultValue={DEFAULT_SHORTCUTS.generalAssist} + label="General Assist" + description="Launch SurfSense instantly from any application" + icon={Rocket} + /> + updateShortcut("quickAsk", accel)} + onReset={() => resetShortcut("quickAsk")} + defaultValue={DEFAULT_SHORTCUTS.quickAsk} + label="Quick Assist" + description="Select text anywhere, then ask AI to explain, rewrite, or act on it" + icon={Zap} + /> + updateShortcut("autocomplete", accel)} + onReset={() => resetShortcut("autocomplete")} + defaultValue={DEFAULT_SHORTCUTS.autocomplete} + label="Extreme Assist" + description="AI drafts text using your screen context and knowledge base" + icon={BrainCog} + /> +

+ Click a shortcut and press a new key combination to change it. +

+
+ ) : ( +
+ +
+ )} +
+
+ + {/* Extreme Assist Toggle */} + + + Extreme Assist Get inline writing suggestions powered by your knowledge base as you type in any app. @@ -60,7 +195,7 @@ export function DesktopContent() {

Show suggestions while typing in other applications. diff --git a/surfsense_web/app/dashboard/layout.tsx b/surfsense_web/app/dashboard/layout.tsx index f727a2018..1f5481b15 100644 --- a/surfsense_web/app/dashboard/layout.tsx +++ b/surfsense_web/app/dashboard/layout.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; -import { getBearerToken, redirectToLogin } from "@/lib/auth-utils"; +import { ensureTokensFromElectron, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; import { queryClient } from "@/lib/query-client/client"; interface DashboardLayoutProps { @@ -17,15 +17,20 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) { useGlobalLoadingEffect(isCheckingAuth); useEffect(() => { - // Check if user is authenticated - const token = getBearerToken(); - if (!token) { - // Save current path and redirect to login - redirectToLogin(); - return; + async function checkAuth() { + let token = getBearerToken(); + if (!token) { + const synced = await ensureTokensFromElectron(); + if (synced) token = getBearerToken(); + } + if (!token) { + redirectToLogin(); + return; + } + queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] }); + setIsCheckingAuth(false); } - queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] }); - setIsCheckingAuth(false); + checkAuth(); }, []); // Return null while loading - the global provider handles the loading UI diff --git a/surfsense_web/app/desktop/login/page.tsx b/surfsense_web/app/desktop/login/page.tsx new file mode 100644 index 000000000..744680010 --- /dev/null +++ b/surfsense_web/app/desktop/login/page.tsx @@ -0,0 +1,284 @@ +"use client"; + +import { IconBrandGoogleFilled } from "@tabler/icons-react"; +import { useAtom } from "jotai"; +import { BrainCog, Eye, EyeOff, Rocket, Zap } from "lucide-react"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; +import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Spinner } from "@/components/ui/spinner"; +import { useElectronAPI } from "@/hooks/use-platform"; +import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; +import { setBearerToken } from "@/lib/auth-utils"; +import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; + +const isGoogleAuth = AUTH_TYPE === "GOOGLE"; + +export default function DesktopLoginPage() { + const router = useRouter(); + const api = useElectronAPI(); + const [{ mutateAsync: login, isPending: isLoggingIn }] = useAtom(loginMutationAtom); + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [loginError, setLoginError] = useState(null); + + const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS); + const [shortcutsLoaded, setShortcutsLoaded] = useState(false); + + useEffect(() => { + if (!api?.getShortcuts) { + setShortcutsLoaded(true); + return; + } + api + .getShortcuts() + .then((config) => { + if (config) setShortcuts(config); + setShortcutsLoaded(true); + }) + .catch(() => setShortcutsLoaded(true)); + }, [api]); + + const updateShortcut = useCallback( + (key: "generalAssist" | "quickAsk" | "autocomplete", accelerator: string) => { + setShortcuts((prev) => { + const updated = { ...prev, [key]: accelerator }; + api?.setShortcuts?.({ [key]: accelerator }).catch(() => { + toast.error("Failed to update shortcut"); + }); + return updated; + }); + toast.success("Shortcut updated"); + }, + [api] + ); + + const resetShortcut = useCallback( + (key: "generalAssist" | "quickAsk" | "autocomplete") => { + updateShortcut(key, DEFAULT_SHORTCUTS[key]); + }, + [updateShortcut] + ); + + const handleGoogleLogin = () => { + window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; + }; + + const autoSetSearchSpace = async () => { + try { + const stored = await api?.getActiveSearchSpace?.(); + if (stored) return; + const spaces = await searchSpacesApiService.getSearchSpaces(); + if (spaces?.length) { + await api?.setActiveSearchSpace?.(String(spaces[0].id)); + } + } catch { + // non-critical — dashboard-sync will catch it later + } + }; + + const handleLocalLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setLoginError(null); + + try { + const data = await login({ + username: email, + password, + grant_type: "password", + }); + + if (typeof window !== "undefined") { + sessionStorage.setItem("login_success_tracked", "true"); + } + + setBearerToken(data.access_token); + await autoSetSearchSpace(); + + setTimeout(() => { + router.push(`/auth/callback?token=${data.access_token}`); + }, 300); + } catch (err) { + if (err instanceof Error) { + setLoginError(err.message); + } else { + setLoginError("Login failed. Please check your credentials."); + } + } + }; + + return ( +

+ {/* Subtle radial glow */} +
+
+
+ +
+ {/* Header */} +
+ SurfSense +

+ Welcome to SurfSense Desktop +

+

+ Configure shortcuts, then sign in to get started. +

+
+ + {/* Scrollable content */} +
+
+ {/* ---- Shortcuts ---- */} + {shortcutsLoaded ? ( +
+

+ Keyboard Shortcuts +

+
+ updateShortcut("generalAssist", accel)} + onReset={() => resetShortcut("generalAssist")} + defaultValue={DEFAULT_SHORTCUTS.generalAssist} + label="General Assist" + description="Launch SurfSense instantly from any application" + icon={Rocket} + /> + updateShortcut("quickAsk", accel)} + onReset={() => resetShortcut("quickAsk")} + defaultValue={DEFAULT_SHORTCUTS.quickAsk} + label="Quick Assist" + description="Select text anywhere, then ask AI to explain, rewrite, or act on it" + icon={Zap} + /> + updateShortcut("autocomplete", accel)} + onReset={() => resetShortcut("autocomplete")} + defaultValue={DEFAULT_SHORTCUTS.autocomplete} + label="Extreme Assist" + description="AI drafts text using your screen context and knowledge base" + icon={BrainCog} + /> +
+

+ Click a shortcut and press a new key combination to change it. +

+
+ ) : ( +
+ +
+ )} + + + + {/* ---- Auth ---- */} +
+

+ Sign In +

+ + {isGoogleAuth ? ( + + ) : ( +
+ {loginError && ( +
+ {loginError} +
+ )} + +
+ + setEmail(e.target.value)} + disabled={isLoggingIn} + autoFocus + className="h-9" + /> +
+ +
+ +
+ setPassword(e.target.value)} + disabled={isLoggingIn} + className="h-9 pr-9" + /> + +
+
+ + +
+ )} +
+
+
+
+
+ ); +} diff --git a/surfsense_web/app/desktop/permissions/page.tsx b/surfsense_web/app/desktop/permissions/page.tsx index 37cfe826f..a2fadc8ff 100644 --- a/surfsense_web/app/desktop/permissions/page.tsx +++ b/surfsense_web/app/desktop/permissions/page.tsx @@ -5,6 +5,7 @@ import { useEffect, useState } from "react"; import { Logo } from "@/components/Logo"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { useElectronAPI } from "@/hooks/use-platform"; type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted" | "limited"; @@ -58,19 +59,18 @@ function StatusBadge({ status }: { status: PermissionStatus }) { export default function DesktopPermissionsPage() { const router = useRouter(); + const api = useElectronAPI(); const [permissions, setPermissions] = useState(null); - const [isElectron, setIsElectron] = useState(false); useEffect(() => { - if (!window.electronAPI) return; - setIsElectron(true); + if (!api) return; let interval: ReturnType | null = null; const isResolved = (s: string) => s === "authorized" || s === "restricted"; const poll = async () => { - const status = await window.electronAPI!.getPermissionsStatus(); + const status = await api.getPermissionsStatus(); setPermissions(status); if (isResolved(status.accessibility) && isResolved(status.screenRecording)) { @@ -83,9 +83,9 @@ export default function DesktopPermissionsPage() { return () => { if (interval) clearInterval(interval); }; - }, []); + }, [api]); - if (!isElectron) { + if (!api) { return (

This page is only available in the desktop app.

@@ -106,15 +106,15 @@ export default function DesktopPermissionsPage() { const handleRequest = async (action: string) => { if (action === "requestScreenRecording") { - await window.electronAPI!.requestScreenRecording(); + await api.requestScreenRecording(); } else if (action === "requestAccessibility") { - await window.electronAPI!.requestAccessibility(); + await api.requestAccessibility(); } }; const handleContinue = () => { if (allGranted) { - window.electronAPI!.restartApp(); + api.restartApp(); } }; @@ -206,6 +206,7 @@ export default function DesktopPermissionsPage() { Grant permissions to continue + + )} ); diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx index 0afc192da..bbbf6dd57 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx @@ -4,6 +4,7 @@ import { Search } from "lucide-react"; import type { FC } from "react"; import { EnumConnectorName } from "@/contracts/enums/connector"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { usePlatform } from "@/hooks/use-platform"; import { isSelfHosted } from "@/lib/env-config"; import { ConnectorCard } from "../components/connector-card"; import { @@ -75,9 +76,8 @@ export const AllConnectorsTab: FC = ({ onManage, onViewAccountsList, }) => { - // Check if self-hosted mode (for showing self-hosted only connectors) const selfHosted = isSelfHosted(); - const isDesktop = typeof window !== "undefined" && !!window.electronAPI; + const { isDesktop } = usePlatform(); const matchesSearch = (title: string, description: string) => title.toLowerCase().includes(searchQuery.toLowerCase()) || diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index af7a8397c..2d55f4d20 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -24,6 +24,7 @@ export interface MentionedDocument { export interface InlineMentionEditorRef { focus: () => void; clear: () => void; + setText: (text: string) => void; getText: () => string; getMentionedDocuments: () => MentionedDocument[]; insertDocumentChip: (doc: Pick) => void; @@ -397,6 +398,19 @@ export const InlineMentionEditor = forwardRef { + if (!editorRef.current) return; + editorRef.current.innerText = text; + const empty = text.length === 0; + setIsEmpty(empty); + onChange?.(text, Array.from(mentionedDocs.values())); + focusAtEnd(); + }, + [focusAtEnd, onChange, mentionedDocs] + ); + const setDocumentChipStatus = useCallback( ( docId: number, @@ -469,6 +483,7 @@ export const InlineMentionEditor = forwardRef ({ focus: () => editorRef.current?.focus(), clear, + setText, getText, getMentionedDocuments, insertDocumentChip, diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 597fcce39..e0086cd66 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -89,17 +89,10 @@ import type { Document } from "@/contracts/types/document.types"; import { useBatchCommentsPreload } from "@/hooks/use-comments"; import { useCommentsSync } from "@/hooks/use-comments-sync"; import { useMediaQuery } from "@/hooks/use-media-query"; +import { useElectronAPI } from "@/hooks/use-platform"; import { cn } from "@/lib/utils"; -/** Placeholder texts that cycle in new chats when input is empty */ -const CYCLING_PLACEHOLDERS = [ - "Ask SurfSense anything or @mention docs", - "Generate a podcast from my vacation ideas in Notion", - "Sum up last week's meeting notes from Drive in a bulleted list", - "Give me a brief overview of the most urgent tickets in Jira and Linear", - "Briefly, what are today's top ten important emails and calendar events?", - "Check if this week's Slack messages reference any GitHub issues", -]; +const COMPOSER_PLACEHOLDER = "Ask anything · Type / for prompts · Type @ to mention docs"; export const Thread: FC = () => { return ; @@ -362,45 +355,23 @@ const Composer: FC = () => { }; }, []); + const electronAPI = useElectronAPI(); const [clipboardInitialText, setClipboardInitialText] = useState(); const clipboardLoadedRef = useRef(false); useEffect(() => { - if (!window.electronAPI || clipboardLoadedRef.current) return; + if (!electronAPI || clipboardLoadedRef.current) return; clipboardLoadedRef.current = true; - window.electronAPI.getQuickAskText().then((text) => { + electronAPI.getQuickAskText().then((text) => { if (text) { setClipboardInitialText(text); - setShowPromptPicker(true); } }); - }, []); + }, [electronAPI]); const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty); const isThreadRunning = useAuiState(({ thread }) => thread.isRunning); - // Cycling placeholder state - only cycles in new chats - const [placeholderIndex, setPlaceholderIndex] = useState(0); - - // Cycle through placeholders every 4 seconds when thread is empty (new chat) - useEffect(() => { - // Only cycle when thread is empty (new chat) - if (!isThreadEmpty) { - // Reset to first placeholder when chat becomes active - setPlaceholderIndex(0); - return; - } - - const intervalId = setInterval(() => { - setPlaceholderIndex((prev) => (prev + 1) % CYCLING_PLACEHOLDERS.length); - }, 6000); - - return () => clearInterval(intervalId); - }, [isThreadEmpty]); - - // Compute current placeholder - only cycle in new chats - const currentPlaceholder = isThreadEmpty - ? CYCLING_PLACEHOLDERS[placeholderIndex] - : CYCLING_PLACEHOLDERS[0]; + const currentPlaceholder = COMPOSER_PLACEHOLDER; // Live collaboration state const { data: currentUser } = useAtomValue(currentUserAtom); @@ -504,34 +475,28 @@ const Composer: FC = () => { : userText ? `${action.prompt}\n\n${userText}` : action.prompt; + editorRef.current?.setText(finalPrompt); aui.composer().setText(finalPrompt); - aui.composer().send(); - editorRef.current?.clear(); setShowPromptPicker(false); setActionQuery(""); - setMentionedDocuments([]); - setSidebarDocs([]); }, - [actionQuery, aui, setMentionedDocuments, setSidebarDocs] + [actionQuery, aui] ); const handleQuickAskSelect = useCallback( (action: { name: string; prompt: string; mode: "transform" | "explore" }) => { if (!clipboardInitialText) return; - window.electronAPI?.setQuickAskMode(action.mode); + electronAPI?.setQuickAskMode(action.mode); const finalPrompt = action.prompt.includes("{selection}") ? action.prompt.replace("{selection}", () => clipboardInitialText) : `${action.prompt}\n\n${clipboardInitialText}`; + editorRef.current?.setText(finalPrompt); aui.composer().setText(finalPrompt); - aui.composer().send(); - editorRef.current?.clear(); setShowPromptPicker(false); setActionQuery(""); setClipboardInitialText(undefined); - setMentionedDocuments([]); - setSidebarDocs([]); }, - [clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs] + [clipboardInitialText, electronAPI, aui] ); // Keyboard navigation for document/action picker (arrow keys, Enter, Escape) diff --git a/surfsense_web/components/desktop/shortcut-recorder.tsx b/surfsense_web/components/desktop/shortcut-recorder.tsx new file mode 100644 index 000000000..ec4e5a528 --- /dev/null +++ b/surfsense_web/components/desktop/shortcut-recorder.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { RotateCcw } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// Accelerator <-> display helpers +// --------------------------------------------------------------------------- + +export function keyEventToAccelerator(e: React.KeyboardEvent): string | null { + const parts: string[] = []; + if (e.ctrlKey || e.metaKey) parts.push("CommandOrControl"); + if (e.altKey) parts.push("Alt"); + if (e.shiftKey) parts.push("Shift"); + + const key = e.key; + if (["Control", "Meta", "Alt", "Shift"].includes(key)) return null; + + if (key === " ") parts.push("Space"); + else if (key.length === 1) parts.push(key.toUpperCase()); + else parts.push(key); + + if (parts.length < 2) return null; + return parts.join("+"); +} + +export function acceleratorToDisplay(accel: string): string[] { + if (!accel) return []; + return accel.split("+").map((part) => { + if (part === "CommandOrControl") return "Ctrl"; + if (part === "Space") return "Space"; + return part; + }); +} + +export const DEFAULT_SHORTCUTS = { + generalAssist: "CommandOrControl+Shift+S", + quickAsk: "CommandOrControl+Alt+S", + autocomplete: "CommandOrControl+Shift+Space", +}; + +// --------------------------------------------------------------------------- +// Kbd pill component +// --------------------------------------------------------------------------- + +export function Kbd({ keys, className }: { keys: string[]; className?: string }) { + return ( + + {keys.map((key, i) => ( + 3 && "px-1.5" + )} + > + {key} + + ))} + + ); +} + +// --------------------------------------------------------------------------- +// Shortcut recorder component +// --------------------------------------------------------------------------- + +export function ShortcutRecorder({ + value, + onChange, + onReset, + defaultValue, + label, + description, + icon: Icon, +}: { + value: string; + onChange: (accelerator: string) => void; + onReset: () => void; + defaultValue: string; + label: string; + description: string; + icon: React.ElementType; +}) { + const [recording, setRecording] = useState(false); + const inputRef = useRef(null); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!recording) return; + e.preventDefault(); + e.stopPropagation(); + + if (e.key === "Escape") { + setRecording(false); + return; + } + + const accel = keyEventToAccelerator(e); + if (accel) { + onChange(accel); + setRecording(false); + } + }, + [recording, onChange] + ); + + const displayKeys = acceleratorToDisplay(value); + const isDefault = value === defaultValue; + + return ( +
+ {/* Icon */} +
+ +
+ + {/* Label + description */} +
+

{label}

+

{description}

+
+ + {/* Actions */} +
+ {!isDefault && ( + + )} + +
+
+ ); +} diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx index 299cf1032..c8dde97ee 100644 --- a/surfsense_web/components/homepage/hero-section.tsx +++ b/surfsense_web/components/homepage/hero-section.tsx @@ -1,39 +1,15 @@ "use client"; +import { Monitor } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; -import dynamic from "next/dynamic"; import Link from "next/link"; -import type React from "react"; -import { useEffect, useRef, useState } from "react"; +import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import Balancer from "react-wrap-balancer"; +import { ExpandedMediaOverlay, useExpandedMedia } from "@/components/ui/expanded-gif-overlay"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; import { trackLoginAttempt } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; -const HeroCarousel = dynamic( - () => import("@/components/ui/hero-carousel").then((m) => ({ default: m.HeroCarousel })), - { - ssr: false, - loading: () => ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ), - } -); - -// Official Google "G" logo with brand colors const GoogleLogo = ({ className }: { className?: string }) => ( ( ); -function useIsDesktop(breakpoint = 1024) { - const [isDesktop, setIsDesktop] = useState(false); - useEffect(() => { - const mql = window.matchMedia(`(min-width: ${breakpoint}px)`); - setIsDesktop(mql.matches); - const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches); - mql.addEventListener("change", handler); - return () => mql.removeEventListener("change", handler); - }, [breakpoint]); - return isDesktop; -} +const TAB_ITEMS = [ + { + title: "Connect & Sync", + description: + "Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.", + src: "/homepage/hero_tutorial/ConnectorFlowGif.mp4", + featured: true, + }, + { + title: "Upload Documents", + description: "Upload documents directly, from images to massive PDFs.", + src: "/homepage/hero_tutorial/DocUploadGif.mp4", + featured: true, + }, + { + title: "Search & Citation", + description: "Ask questions and get cited responses from your knowledge base.", + src: "/homepage/hero_tutorial/BSNCGif.mp4", + featured: false, + }, + { + title: "Document Q&A", + description: "Mention specific documents in chat for targeted answers.", + src: "/homepage/hero_tutorial/BQnaGif_compressed.mp4", + featured: false, + }, + { + title: "Reports", + description: "Generate reports from your sources in many formats.", + src: "/homepage/hero_tutorial/ReportGenGif_compressed.mp4", + featured: false, + }, + { + title: "Podcasts", + description: "Turn anything into a podcast in under 20 seconds.", + src: "/homepage/hero_tutorial/PodcastGenGif.mp4", + featured: false, + }, + { + title: "Image Generation", + description: "Generate high-quality images easily from your conversations.", + src: "/homepage/hero_tutorial/ImageGenGif.mp4", + featured: false, + }, + { + title: "Collaborative Chat", + description: "Collaborate on AI-powered conversations in realtime with your team.", + src: "/homepage/hero_realtime/RealTimeChatGif.mp4", + featured: false, + }, + { + title: "Comments", + description: "Add comments and tag teammates on any message.", + src: "/homepage/hero_realtime/RealTimeCommentsFlow.mp4", + featured: false, + }, + { + title: "Video Generation", + description: "Create short videos with AI-generated visuals and narration from your sources.", + src: "/homepage/hero_tutorial/video_gen_surf.mp4", + featured: false, + }, +] as const; export function HeroSection() { - const containerRef = useRef(null); - const parentRef = useRef(null); - const isDesktop = useIsDesktop(); - return ( -
- - {isDesktop && ( - <> - - - - - - )} +
+
+

+ NotebookLM for Teams +

+
+
+

+ An open source, privacy focused alternative to NotebookLM for teams with no data + limits. +

-

-
-
- NotebookLM for Teams +
+ +
+
-

-

- Connect any LLM to your internal knowledge sources and chat with it in real time alongside - your team. -

-
- - {/* */} -
-
- +
); @@ -158,256 +146,247 @@ function GetStartedButton() { if (isGoogleAuth) { return ( - - {/* Animated gradient background on hover */} - - {/* Google logo with subtle animation */} - - - - Continue with Google - + + Continue with Google + ); } return ( - - - Get Started - - + + Get Started + ); } -const BackgroundGrids = () => { - return ( -
-
- - -
-
- - -
-
- - -
-
- - -
-
- ); -}; - -const CollisionMechanism = ({ - parentRef, - beamOptions = {}, -}: { - parentRef: React.RefObject; - beamOptions?: { - initialX?: number; - translateX?: number; - initialY?: number; - translateY?: number; - rotate?: number; - className?: string; - duration?: number; - delay?: number; - repeatDelay?: number; - }; -}) => { - const beamRef = useRef(null); - const [collision, setCollision] = useState<{ - detected: boolean; - coordinates: { x: number; y: number } | null; - }>({ detected: false, coordinates: null }); - const [beamKey, setBeamKey] = useState(0); - const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false); - - useEffect(() => { - const checkCollision = () => { - if (beamRef.current && parentRef.current && !cycleCollisionDetected) { - const beamRect = beamRef.current.getBoundingClientRect(); - const parentRect = parentRef.current.getBoundingClientRect(); - const rightEdge = parentRect.right; - - if (beamRect.right >= rightEdge - 20) { - const relativeX = parentRect.width - 20; - const relativeY = beamRect.top - parentRect.top + beamRect.height / 2; - - setCollision({ - detected: true, - coordinates: { x: relativeX, y: relativeY }, - }); - setCycleCollisionDetected(true); - if (beamRef.current) { - beamRef.current.style.opacity = "0"; - } - } - } - }; - - const animationInterval = setInterval(checkCollision, 100); - - return () => clearInterval(animationInterval); - }, [cycleCollisionDetected, parentRef]); - - useEffect(() => { - if (!collision.detected || !collision.coordinates) return; - - const timer1 = setTimeout(() => { - setCollision({ detected: false, coordinates: null }); - setCycleCollisionDetected(false); - if (beamRef.current) { - beamRef.current.style.opacity = "1"; - } - }, 2000); - - const timer2 = setTimeout(() => { - setBeamKey((prevKey) => prevKey + 1); - }, 2000); - - return () => { - clearTimeout(timer1); - clearTimeout(timer2); - }; - }, [collision]); +const BrowserWindow = () => { + const [selectedIndex, setSelectedIndex] = useState(0); + const selectedItem = TAB_ITEMS[selectedIndex]; + const { expanded, open, close } = useExpandedMedia(); return ( <> - + +
+
+
+
+
+
+
+ {TAB_ITEMS.map((item, index) => ( + + + {index !== TAB_ITEMS.length - 1 && ( +
+ )} + + ))} +
+
+
+ + +
+
+

+ {selectedItem.title} +

+

+ {selectedItem.description} +

+
+
+ +
+
+
+ + - {collision.detected && collision.coordinates && ( - + {expanded && ( + )} ); }; -const Explosion = ({ ...props }: React.HTMLProps) => { - const spans = Array.from({ length: 20 }, (_, index) => ({ - id: index, - initialX: 0, - initialY: 0, - directionX: Math.floor(Math.random() * 80 - 40), - directionY: Math.floor(Math.random() * -50 - 10), - })); +const TabVideo = memo(function TabVideo({ src }: { src: string }) { + const videoRef = useRef(null); + const [hasLoaded, setHasLoaded] = useState(false); + + useEffect(() => { + setHasLoaded(false); + const video = videoRef.current; + if (!video) return; + video.currentTime = 0; + video.play().catch(() => {}); + }, [src]); + + const handleCanPlay = useCallback(() => { + setHasLoaded(true); + }, []); return ( -
- - {spans.map((span) => ( - - ))} +
+