diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 491df0992..784dffb32 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -5,6 +5,20 @@ on: tags: - 'v*' - 'beta-v*' + workflow_dispatch: + inputs: + version: + description: 'Version number (e.g. 0.0.15) — used for dry-run testing without a tag' + required: true + default: '0.0.0-test' + publish: + description: 'Publish to GitHub Releases' + required: true + type: choice + options: + - never + - always + default: 'never' permissions: contents: write @@ -25,24 +39,28 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - - name: Extract version from tag + - name: Extract version id: version shell: bash run: | - TAG=${GITHUB_REF#refs/tags/} - VERSION=${TAG#beta-} - VERSION=${VERSION#v} + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ inputs.version }}" + else + TAG=${GITHUB_REF#refs/tags/} + VERSION=${TAG#beta-} + VERSION=${VERSION#v} + fi echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v5 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: 20 + node-version: 22 cache: 'pnpm' cache-dependency-path: | surfsense_web/pnpm-lock.yaml @@ -60,6 +78,7 @@ jobs: NEXT_PUBLIC_ZERO_CACHE_URL: ${{ vars.NEXT_PUBLIC_ZERO_CACHE_URL }} NEXT_PUBLIC_DEPLOYMENT_MODE: ${{ vars.NEXT_PUBLIC_DEPLOYMENT_MODE }} NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${{ vars.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE }} + NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }} - name: Install desktop dependencies run: pnpm install @@ -70,9 +89,12 @@ jobs: working-directory: surfsense_desktop env: HOSTED_FRONTEND_URL: ${{ vars.HOSTED_FRONTEND_URL }} + POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} + POSTHOG_HOST: ${{ vars.POSTHOG_HOST }} - name: Package & Publish - run: pnpm exec electron-builder ${{ matrix.platform }} --config electron-builder.yml --publish always -c.extraMetadata.version=${{ steps.version.outputs.VERSION }} + shell: bash + run: pnpm exec electron-builder ${{ matrix.platform }} --config electron-builder.yml --publish ${{ inputs.publish || 'always' }} -c.extraMetadata.version=${{ steps.version.outputs.VERSION }} working-directory: surfsense_desktop env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/package.json b/package.json new file mode 100644 index 000000000..8a1a6add8 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "surfsense", + "private": true, + "packageManager": "pnpm@10.24.0" +} diff --git a/surfsense_backend/alembic/versions/120_add_vision_llm_configs_table.py b/surfsense_backend/alembic/versions/120_add_vision_llm_configs_table.py new file mode 100644 index 000000000..c0c915388 --- /dev/null +++ b/surfsense_backend/alembic/versions/120_add_vision_llm_configs_table.py @@ -0,0 +1,190 @@ +"""Add vision LLM configs table and rename preference column + +Revision ID: 120 +Revises: 119 + +Changes: +1. Create visionprovider enum type +2. Create vision_llm_configs table +3. Rename vision_llm_id -> vision_llm_config_id on searchspaces +4. Add vision config permissions to existing system roles +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import ENUM as PG_ENUM, UUID + +from alembic import op + +revision: str = "120" +down_revision: str | None = "119" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +VISION_PROVIDER_VALUES = ( + "OPENAI", + "ANTHROPIC", + "GOOGLE", + "AZURE_OPENAI", + "VERTEX_AI", + "BEDROCK", + "XAI", + "OPENROUTER", + "OLLAMA", + "GROQ", + "TOGETHER_AI", + "FIREWORKS_AI", + "DEEPSEEK", + "MISTRAL", + "CUSTOM", +) + + +def upgrade() -> None: + connection = op.get_bind() + + # 1. Create visionprovider enum + connection.execute( + sa.text( + """ + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'visionprovider') THEN + CREATE TYPE visionprovider AS ENUM ( + 'OPENAI', 'ANTHROPIC', 'GOOGLE', 'AZURE_OPENAI', 'VERTEX_AI', + 'BEDROCK', 'XAI', 'OPENROUTER', 'OLLAMA', 'GROQ', + 'TOGETHER_AI', 'FIREWORKS_AI', 'DEEPSEEK', 'MISTRAL', 'CUSTOM' + ); + END IF; + END + $$; + """ + ) + ) + + # 2. Create vision_llm_configs table + result = connection.execute( + sa.text( + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'vision_llm_configs')" + ) + ) + if not result.scalar(): + op.create_table( + "vision_llm_configs", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("description", sa.String(500), nullable=True), + sa.Column( + "provider", + PG_ENUM(*VISION_PROVIDER_VALUES, name="visionprovider", create_type=False), + nullable=False, + ), + sa.Column("custom_provider", sa.String(100), nullable=True), + sa.Column("model_name", sa.String(100), nullable=False), + sa.Column("api_key", sa.String(), nullable=False), + sa.Column("api_base", sa.String(500), nullable=True), + sa.Column("api_version", sa.String(50), nullable=True), + sa.Column("litellm_params", sa.JSON(), nullable=True), + sa.Column("search_space_id", sa.Integer(), nullable=False), + sa.Column("user_id", UUID(as_uuid=True), nullable=False), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint( + ["search_space_id"], ["searchspaces.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["user_id"], ["user.id"], ondelete="CASCADE" + ), + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_vision_llm_configs_name " + "ON vision_llm_configs (name)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_vision_llm_configs_search_space_id " + "ON vision_llm_configs (search_space_id)" + ) + + # 3. Rename vision_llm_id -> vision_llm_config_id on searchspaces + existing_columns = [ + col["name"] for col in sa.inspect(connection).get_columns("searchspaces") + ] + if "vision_llm_id" in existing_columns and "vision_llm_config_id" not in existing_columns: + op.alter_column("searchspaces", "vision_llm_id", new_column_name="vision_llm_config_id") + elif "vision_llm_config_id" not in existing_columns: + op.add_column( + "searchspaces", + sa.Column("vision_llm_config_id", sa.Integer(), nullable=True, server_default="0"), + ) + + # 4. Add vision config permissions to existing system roles + connection.execute( + sa.text( + """ + UPDATE search_space_roles + SET permissions = array_cat( + permissions, + ARRAY['vision_configs:create', 'vision_configs:read'] + ) + WHERE is_system_role = true + AND name = 'Editor' + AND NOT ('vision_configs:create' = ANY(permissions)) + """ + ) + ) + connection.execute( + sa.text( + """ + UPDATE search_space_roles + SET permissions = array_cat( + permissions, + ARRAY['vision_configs:read'] + ) + WHERE is_system_role = true + AND name = 'Viewer' + AND NOT ('vision_configs:read' = ANY(permissions)) + """ + ) + ) + + +def downgrade() -> None: + connection = op.get_bind() + + # Remove permissions + connection.execute( + sa.text( + """ + UPDATE search_space_roles + SET permissions = array_remove( + array_remove( + array_remove(permissions, 'vision_configs:create'), + 'vision_configs:read' + ), + 'vision_configs:delete' + ) + WHERE is_system_role = true + """ + ) + ) + + # Rename column back + existing_columns = [ + col["name"] for col in sa.inspect(connection).get_columns("searchspaces") + ] + if "vision_llm_config_id" in existing_columns: + op.alter_column("searchspaces", "vision_llm_config_id", new_column_name="vision_llm_id") + + # Drop table and enum + op.execute("DROP INDEX IF EXISTS ix_vision_llm_configs_search_space_id") + op.execute("DROP INDEX IF EXISTS ix_vision_llm_configs_name") + op.execute("DROP TABLE IF EXISTS vision_llm_configs") + op.execute("DROP TYPE IF EXISTS visionprovider") 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..77c0af5bb --- /dev/null +++ b/surfsense_backend/app/agents/autocomplete/autocomplete_agent.py @@ -0,0 +1,497 @@ +"""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 json +import logging +import re +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: +- 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 an empty JSON array: [] + +## Output Format + +You MUST provide exactly 3 different suggestion options. Each should be a distinct, plausible completion — vary the tone, detail level, or angle. + +Return your suggestions as a JSON array of exactly 3 strings. Output ONLY the JSON array, nothing else — no markdown fences, no explanation, no commentary. + +Example format: +["First suggestion text here.", "Second suggestion — a different take.", "Third option with another approach."] + +## 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 + + +# --------------------------------------------------------------------------- +# JSON suggestion parsing (with fallback) +# --------------------------------------------------------------------------- + + +def _parse_suggestions(raw: str) -> list[str]: + """Extract a list of suggestion strings from the agent's output. + + Tries, in order: + 1. Direct ``json.loads`` + 2. Extract content between ```json ... ``` fences + 3. Find the first ``[`` … ``]`` span + Falls back to wrapping the raw text as a single suggestion. + """ + text = raw.strip() + if not text: + return [] + + for candidate in _json_candidates(text): + try: + parsed = json.loads(candidate) + if isinstance(parsed, list) and all(isinstance(s, str) for s in parsed): + return [s for s in parsed if s.strip()] + except (json.JSONDecodeError, ValueError): + continue + + return [text] + + +def _json_candidates(text: str) -> list[str]: + """Yield candidate JSON strings from raw text.""" + candidates = [text] + + fence = re.search(r"```(?:json)?\s*\n?(.*?)```", text, re.DOTALL) + if fence: + candidates.append(fence.group(1).strip()) + + bracket = re.search(r"\[.*]", text, re.DOTALL) + if bracket: + candidates.append(bracket.group(0)) + + return candidates + + +# --------------------------------------------------------------------------- +# 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}} + + text_buffer: list[str] = [] + 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() + + gen_step_id = next_thinking_step_id() + last_active_step_id = gen_step_id + step_titles[gen_step_id] = "Generating suggestions" + yield streaming_service.format_thinking_step( + step_id=gen_step_id, + title="Generating suggestions", + 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): + text_buffer.append(content) + + elif event_type == "on_chat_model_end": + if active_tool_depth > 0: + continue + if "surfsense:internal" in event.get("tags", []): + continue + output = event.get("data", {}).get("output") + if output and hasattr(output, "content"): + if getattr(output, "tool_calls", None): + continue + content = output.content + if content and isinstance(content, str) and not text_buffer: + text_buffer.append(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", {}) + + 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 + + step_event = complete_current_step() + if step_event: + yield step_event + + raw_text = "".join(text_buffer) + suggestions = _parse_suggestions(raw_text) + + yield streaming_service.format_data( + "suggestions", {"options": suggestions} + ) + + 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) + 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/app.py b/surfsense_backend/app/app.py index bba2f1f3a..7b2b421ac 100644 --- a/surfsense_backend/app/app.py +++ b/surfsense_backend/app/app.py @@ -25,7 +25,12 @@ from app.agents.new_chat.checkpointer import ( close_checkpointer, setup_checkpointer_tables, ) -from app.config import config, initialize_image_gen_router, initialize_llm_router +from app.config import ( + config, + initialize_image_gen_router, + initialize_llm_router, + initialize_vision_llm_router, +) from app.db import User, create_db_and_tables, get_async_session from app.routes import router as crud_router from app.routes.auth_routes import router as auth_router @@ -223,6 +228,7 @@ async def lifespan(app: FastAPI): await setup_checkpointer_tables() initialize_llm_router() initialize_image_gen_router() + initialize_vision_llm_router() try: await asyncio.wait_for(seed_surfsense_docs(), timeout=120) except TimeoutError: diff --git a/surfsense_backend/app/celery_app.py b/surfsense_backend/app/celery_app.py index 684da6a13..bf2fdcb39 100644 --- a/surfsense_backend/app/celery_app.py +++ b/surfsense_backend/app/celery_app.py @@ -18,10 +18,15 @@ def init_worker(**kwargs): This ensures the Auto mode (LiteLLM Router) is available for background tasks like document summarization and image generation. """ - from app.config import initialize_image_gen_router, initialize_llm_router + from app.config import ( + initialize_image_gen_router, + initialize_llm_router, + initialize_vision_llm_router, + ) initialize_llm_router() initialize_image_gen_router() + initialize_vision_llm_router() # Get Celery configuration from environment diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 0011ea289..575db4c7b 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -102,6 +102,44 @@ def load_global_image_gen_configs(): return [] +def load_global_vision_llm_configs(): + global_config_file = BASE_DIR / "app" / "config" / "global_llm_config.yaml" + + if not global_config_file.exists(): + return [] + + try: + with open(global_config_file, encoding="utf-8") as f: + data = yaml.safe_load(f) + return data.get("global_vision_llm_configs", []) + except Exception as e: + print(f"Warning: Failed to load global vision LLM configs: {e}") + return [] + + +def load_vision_llm_router_settings(): + default_settings = { + "routing_strategy": "usage-based-routing", + "num_retries": 3, + "allowed_fails": 3, + "cooldown_time": 60, + } + + global_config_file = BASE_DIR / "app" / "config" / "global_llm_config.yaml" + + if not global_config_file.exists(): + return default_settings + + try: + with open(global_config_file, encoding="utf-8") as f: + data = yaml.safe_load(f) + settings = data.get("vision_llm_router_settings", {}) + return {**default_settings, **settings} + except Exception as e: + print(f"Warning: Failed to load vision LLM router settings: {e}") + return default_settings + + def load_image_gen_router_settings(): """ Load router settings for image generation Auto mode from YAML file. @@ -182,6 +220,29 @@ def initialize_image_gen_router(): print(f"Warning: Failed to initialize Image Generation Router: {e}") +def initialize_vision_llm_router(): + vision_configs = load_global_vision_llm_configs() + router_settings = load_vision_llm_router_settings() + + if not vision_configs: + print( + "Info: No global vision LLM configs found, " + "Vision LLM Auto mode will not be available" + ) + return + + try: + from app.services.vision_llm_router_service import VisionLLMRouterService + + VisionLLMRouterService.initialize(vision_configs, router_settings) + print( + f"Info: Vision LLM Router initialized with {len(vision_configs)} models " + f"(strategy: {router_settings.get('routing_strategy', 'usage-based-routing')})" + ) + except Exception as e: + print(f"Warning: Failed to initialize Vision LLM Router: {e}") + + class Config: # Check if ffmpeg is installed if not is_ffmpeg_installed(): @@ -335,6 +396,12 @@ class Config: # Router settings for Image Generation Auto mode IMAGE_GEN_ROUTER_SETTINGS = load_image_gen_router_settings() + # Global Vision LLM Configurations (optional) + GLOBAL_VISION_LLM_CONFIGS = load_global_vision_llm_configs() + + # Router settings for Vision LLM Auto mode + VISION_LLM_ROUTER_SETTINGS = load_vision_llm_router_settings() + # Chonkie Configuration | Edit this to your needs EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL") # Azure OpenAI credentials from environment variables diff --git a/surfsense_backend/app/config/global_llm_config.example.yaml b/surfsense_backend/app/config/global_llm_config.example.yaml index 49a8d0295..e382fdc74 100644 --- a/surfsense_backend/app/config/global_llm_config.example.yaml +++ b/surfsense_backend/app/config/global_llm_config.example.yaml @@ -263,6 +263,82 @@ global_image_generation_configs: # rpm: 30 # litellm_params: {} +# ============================================================================= +# Vision LLM Configuration +# ============================================================================= +# These configurations power the vision autocomplete feature (screenshot analysis). +# Only vision-capable models should be used here (e.g. GPT-4o, Gemini Pro, Claude 3). +# Supported providers: OpenAI, Anthropic, Google, Azure OpenAI, Vertex AI, Bedrock, +# xAI, OpenRouter, Ollama, Groq, Together AI, Fireworks AI, DeepSeek, Mistral, Custom +# +# Auto mode (ID 0) uses LiteLLM Router for load balancing across all vision configs. + +# Router Settings for Vision LLM Auto Mode +vision_llm_router_settings: + routing_strategy: "usage-based-routing" + num_retries: 3 + allowed_fails: 3 + cooldown_time: 60 + +global_vision_llm_configs: + # Example: OpenAI GPT-4o (recommended for vision) + - id: -1 + name: "Global GPT-4o Vision" + description: "OpenAI's GPT-4o with strong vision capabilities" + provider: "OPENAI" + model_name: "gpt-4o" + api_key: "sk-your-openai-api-key-here" + api_base: "" + rpm: 500 + tpm: 100000 + litellm_params: + temperature: 0.3 + max_tokens: 1000 + + # Example: Google Gemini 2.0 Flash + - id: -2 + name: "Global Gemini 2.0 Flash" + description: "Google's fast vision model with large context" + provider: "GOOGLE" + model_name: "gemini-2.0-flash" + api_key: "your-google-ai-api-key-here" + api_base: "" + rpm: 1000 + tpm: 200000 + litellm_params: + temperature: 0.3 + max_tokens: 1000 + + # Example: Anthropic Claude 3.5 Sonnet + - id: -3 + name: "Global Claude 3.5 Sonnet Vision" + description: "Anthropic's Claude 3.5 Sonnet with vision support" + provider: "ANTHROPIC" + model_name: "claude-3-5-sonnet-20241022" + api_key: "sk-ant-your-anthropic-api-key-here" + api_base: "" + rpm: 1000 + tpm: 100000 + litellm_params: + temperature: 0.3 + max_tokens: 1000 + + # Example: Azure OpenAI GPT-4o + # - id: -4 + # name: "Global Azure GPT-4o Vision" + # description: "Azure-hosted GPT-4o for vision analysis" + # provider: "AZURE_OPENAI" + # model_name: "azure/gpt-4o-deployment" + # api_key: "your-azure-api-key-here" + # api_base: "https://your-resource.openai.azure.com" + # api_version: "2024-02-15-preview" + # rpm: 500 + # tpm: 100000 + # litellm_params: + # temperature: 0.3 + # max_tokens: 1000 + # base_model: "gpt-4o" + # Notes: # - ID 0 is reserved for "Auto" mode - uses LiteLLM Router for load balancing # - Use negative IDs to distinguish global configs from user configs (NewLLMConfig in DB) @@ -283,3 +359,9 @@ global_image_generation_configs: # - The router uses litellm.aimage_generation() for async image generation # - Only RPM (requests per minute) is relevant for image generation rate limiting. # TPM (tokens per minute) does not apply since image APIs are billed/rate-limited per request, not per token. +# +# VISION LLM NOTES: +# - Vision configs use the same ID scheme (negative for global, positive for user DB) +# - Only use vision-capable models (GPT-4o, Gemini, Claude 3, etc.) +# - Lower temperature (0.3) is recommended for accurate screenshot analysis +# - Lower max_tokens (1000) is sufficient since autocomplete produces short suggestions diff --git a/surfsense_backend/app/config/vision_model_list_fallback.json b/surfsense_backend/app/config/vision_model_list_fallback.json new file mode 100644 index 000000000..830eb6517 --- /dev/null +++ b/surfsense_backend/app/config/vision_model_list_fallback.json @@ -0,0 +1,23 @@ +[ + {"value": "gpt-4o", "label": "GPT-4o", "provider": "OPENAI", "context_window": "128K"}, + {"value": "gpt-4o-mini", "label": "GPT-4o Mini", "provider": "OPENAI", "context_window": "128K"}, + {"value": "gpt-4-turbo", "label": "GPT-4 Turbo", "provider": "OPENAI", "context_window": "128K"}, + {"value": "claude-sonnet-4-20250514", "label": "Claude Sonnet 4", "provider": "ANTHROPIC", "context_window": "200K"}, + {"value": "claude-3-7-sonnet-20250219", "label": "Claude 3.7 Sonnet", "provider": "ANTHROPIC", "context_window": "200K"}, + {"value": "claude-3-5-sonnet-20241022", "label": "Claude 3.5 Sonnet", "provider": "ANTHROPIC", "context_window": "200K"}, + {"value": "claude-3-opus-20240229", "label": "Claude 3 Opus", "provider": "ANTHROPIC", "context_window": "200K"}, + {"value": "claude-3-haiku-20240307", "label": "Claude 3 Haiku", "provider": "ANTHROPIC", "context_window": "200K"}, + {"value": "gemini-2.5-flash", "label": "Gemini 2.5 Flash", "provider": "GOOGLE", "context_window": "1M"}, + {"value": "gemini-2.5-pro", "label": "Gemini 2.5 Pro", "provider": "GOOGLE", "context_window": "1M"}, + {"value": "gemini-2.0-flash", "label": "Gemini 2.0 Flash", "provider": "GOOGLE", "context_window": "1M"}, + {"value": "gemini-1.5-pro", "label": "Gemini 1.5 Pro", "provider": "GOOGLE", "context_window": "1M"}, + {"value": "gemini-1.5-flash", "label": "Gemini 1.5 Flash", "provider": "GOOGLE", "context_window": "1M"}, + {"value": "pixtral-large-latest", "label": "Pixtral Large", "provider": "MISTRAL", "context_window": "128K"}, + {"value": "pixtral-12b-2409", "label": "Pixtral 12B", "provider": "MISTRAL", "context_window": "128K"}, + {"value": "grok-2-vision-1212", "label": "Grok 2 Vision", "provider": "XAI", "context_window": "32K"}, + {"value": "llava", "label": "LLaVA", "provider": "OLLAMA"}, + {"value": "bakllava", "label": "BakLLaVA", "provider": "OLLAMA"}, + {"value": "llava-llama3", "label": "LLaVA Llama 3", "provider": "OLLAMA"}, + {"value": "llama-4-scout-17b-16e-instruct", "label": "Llama 4 Scout 17B", "provider": "GROQ", "context_window": "128K"}, + {"value": "meta-llama/Llama-4-Scout-17B-16E-Instruct", "label": "Llama 4 Scout 17B", "provider": "TOGETHER_AI", "context_window": "128K"} +] diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 6e9553307..4689313f7 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -260,6 +260,24 @@ class ImageGenProvider(StrEnum): NSCALE = "NSCALE" +class VisionProvider(StrEnum): + OPENAI = "OPENAI" + ANTHROPIC = "ANTHROPIC" + GOOGLE = "GOOGLE" + AZURE_OPENAI = "AZURE_OPENAI" + VERTEX_AI = "VERTEX_AI" + BEDROCK = "BEDROCK" + XAI = "XAI" + OPENROUTER = "OPENROUTER" + OLLAMA = "OLLAMA" + GROQ = "GROQ" + TOGETHER_AI = "TOGETHER_AI" + FIREWORKS_AI = "FIREWORKS_AI" + DEEPSEEK = "DEEPSEEK" + MISTRAL = "MISTRAL" + CUSTOM = "CUSTOM" + + class LogLevel(StrEnum): DEBUG = "DEBUG" INFO = "INFO" @@ -377,6 +395,11 @@ class Permission(StrEnum): IMAGE_GENERATIONS_READ = "image_generations:read" IMAGE_GENERATIONS_DELETE = "image_generations:delete" + # Vision LLM Configs + VISION_CONFIGS_CREATE = "vision_configs:create" + VISION_CONFIGS_READ = "vision_configs:read" + VISION_CONFIGS_DELETE = "vision_configs:delete" + # Connectors CONNECTORS_CREATE = "connectors:create" CONNECTORS_READ = "connectors:read" @@ -445,6 +468,9 @@ DEFAULT_ROLE_PERMISSIONS = { # Image Generations (create and read, no delete) Permission.IMAGE_GENERATIONS_CREATE.value, Permission.IMAGE_GENERATIONS_READ.value, + # Vision Configs (create and read, no delete) + Permission.VISION_CONFIGS_CREATE.value, + Permission.VISION_CONFIGS_READ.value, # Connectors (no delete) Permission.CONNECTORS_CREATE.value, Permission.CONNECTORS_READ.value, @@ -478,6 +504,8 @@ DEFAULT_ROLE_PERMISSIONS = { Permission.VIDEO_PRESENTATIONS_READ.value, # Image Generations (read only) Permission.IMAGE_GENERATIONS_READ.value, + # Vision Configs (read only) + Permission.VISION_CONFIGS_READ.value, # Connectors (read only) Permission.CONNECTORS_READ.value, # Logs (read only) @@ -1263,6 +1291,35 @@ class ImageGenerationConfig(BaseModel, TimestampMixin): user = relationship("User", back_populates="image_generation_configs") +class VisionLLMConfig(BaseModel, TimestampMixin): + __tablename__ = "vision_llm_configs" + + name = Column(String(100), nullable=False, index=True) + description = Column(String(500), nullable=True) + + provider = Column(SQLAlchemyEnum(VisionProvider), nullable=False) + custom_provider = Column(String(100), nullable=True) + model_name = Column(String(100), nullable=False) + + api_key = Column(String, nullable=False) + api_base = Column(String(500), nullable=True) + api_version = Column(String(50), nullable=True) + + litellm_params = Column(JSON, nullable=True, default={}) + + search_space_id = Column( + Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + ) + search_space = relationship( + "SearchSpace", back_populates="vision_llm_configs" + ) + + user_id = Column( + UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False + ) + user = relationship("User", back_populates="vision_llm_configs") + + class ImageGeneration(BaseModel, TimestampMixin): """ Stores image generation requests and results using litellm.aimage_generation(). @@ -1351,7 +1408,7 @@ class SearchSpace(BaseModel, TimestampMixin): image_generation_config_id = Column( Integer, nullable=True, default=0 ) # For image generation, defaults to Auto mode - vision_llm_id = Column( + vision_llm_config_id = Column( Integer, nullable=True, default=0 ) # For vision/screenshot analysis, defaults to Auto mode @@ -1432,6 +1489,12 @@ class SearchSpace(BaseModel, TimestampMixin): order_by="ImageGenerationConfig.id", cascade="all, delete-orphan", ) + vision_llm_configs = relationship( + "VisionLLMConfig", + back_populates="search_space", + order_by="VisionLLMConfig.id", + cascade="all, delete-orphan", + ) # RBAC relationships roles = relationship( @@ -1961,6 +2024,12 @@ if config.AUTH_TYPE == "GOOGLE": passive_deletes=True, ) + vision_llm_configs = relationship( + "VisionLLMConfig", + back_populates="user", + passive_deletes=True, + ) + # User memories for personalized AI responses memories = relationship( "UserMemory", @@ -2075,6 +2144,12 @@ else: passive_deletes=True, ) + vision_llm_configs = relationship( + "VisionLLMConfig", + back_populates="user", + passive_deletes=True, + ) + # User memories for personalized AI responses memories = relationship( "UserMemory", diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 22631bc1d..02367606b 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -49,6 +49,7 @@ from .stripe_routes import router as stripe_router from .surfsense_docs_routes import router as surfsense_docs_router from .teams_add_connector_route import router as teams_add_connector_router from .video_presentations_routes import router as video_presentations_router +from .vision_llm_routes import router as vision_llm_router from .youtube_routes import router as youtube_router router = APIRouter() @@ -68,6 +69,7 @@ router.include_router( ) # Video presentation status and streaming router.include_router(reports_router) # Report CRUD and multi-format export router.include_router(image_generation_router) # Image generation via litellm +router.include_router(vision_llm_router) # Vision LLM configs for screenshot analysis router.include_router(search_source_connectors_router) router.include_router(google_calendar_add_connector_router) router.include_router(google_gmail_add_connector_router) diff --git a/surfsense_backend/app/routes/search_spaces_routes.py b/surfsense_backend/app/routes/search_spaces_routes.py index c4f1ab035..78be97aa1 100644 --- a/surfsense_backend/app/routes/search_spaces_routes.py +++ b/surfsense_backend/app/routes/search_spaces_routes.py @@ -14,6 +14,7 @@ from app.db import ( SearchSpaceMembership, SearchSpaceRole, User, + VisionLLMConfig, get_async_session, get_default_roles_config, ) @@ -483,6 +484,63 @@ async def _get_image_gen_config_by_id( return None +async def _get_vision_llm_config_by_id( + session: AsyncSession, config_id: int | None +) -> dict | None: + if config_id is None: + return None + + if config_id == 0: + return { + "id": 0, + "name": "Auto (Fastest)", + "description": "Automatically routes requests across available vision LLM providers", + "provider": "AUTO", + "model_name": "auto", + "is_global": True, + "is_auto_mode": True, + } + + if config_id < 0: + for cfg in config.GLOBAL_VISION_LLM_CONFIGS: + if cfg.get("id") == config_id: + return { + "id": cfg.get("id"), + "name": cfg.get("name"), + "description": cfg.get("description"), + "provider": cfg.get("provider"), + "custom_provider": cfg.get("custom_provider"), + "model_name": cfg.get("model_name"), + "api_base": cfg.get("api_base") or None, + "api_version": cfg.get("api_version") or None, + "litellm_params": cfg.get("litellm_params", {}), + "is_global": True, + } + return None + + result = await session.execute( + select(VisionLLMConfig).filter(VisionLLMConfig.id == config_id) + ) + db_config = result.scalars().first() + if db_config: + return { + "id": db_config.id, + "name": db_config.name, + "description": db_config.description, + "provider": db_config.provider.value if db_config.provider else None, + "custom_provider": db_config.custom_provider, + "model_name": db_config.model_name, + "api_base": db_config.api_base, + "api_version": db_config.api_version, + "litellm_params": db_config.litellm_params or {}, + "created_at": db_config.created_at.isoformat() + if db_config.created_at + else None, + "search_space_id": db_config.search_space_id, + } + return None + + @router.get( "/search-spaces/{search_space_id}/llm-preferences", response_model=LLMPreferencesRead, @@ -522,17 +580,19 @@ async def get_llm_preferences( image_generation_config = await _get_image_gen_config_by_id( session, search_space.image_generation_config_id ) - vision_llm = await _get_llm_config_by_id(session, search_space.vision_llm_id) + vision_llm_config = await _get_vision_llm_config_by_id( + session, search_space.vision_llm_config_id + ) return LLMPreferencesRead( agent_llm_id=search_space.agent_llm_id, document_summary_llm_id=search_space.document_summary_llm_id, image_generation_config_id=search_space.image_generation_config_id, - vision_llm_id=search_space.vision_llm_id, + vision_llm_config_id=search_space.vision_llm_config_id, agent_llm=agent_llm, document_summary_llm=document_summary_llm, image_generation_config=image_generation_config, - vision_llm=vision_llm, + vision_llm_config=vision_llm_config, ) except HTTPException: @@ -592,17 +652,19 @@ async def update_llm_preferences( image_generation_config = await _get_image_gen_config_by_id( session, search_space.image_generation_config_id ) - vision_llm = await _get_llm_config_by_id(session, search_space.vision_llm_id) + vision_llm_config = await _get_vision_llm_config_by_id( + session, search_space.vision_llm_config_id + ) return LLMPreferencesRead( agent_llm_id=search_space.agent_llm_id, document_summary_llm_id=search_space.document_summary_llm_id, image_generation_config_id=search_space.image_generation_config_id, - vision_llm_id=search_space.vision_llm_id, + vision_llm_config_id=search_space.vision_llm_config_id, agent_llm=agent_llm, document_summary_llm=document_summary_llm, image_generation_config=image_generation_config, - vision_llm=vision_llm, + vision_llm_config=vision_llm_config, ) except HTTPException: diff --git a/surfsense_backend/app/routes/vision_llm_routes.py b/surfsense_backend/app/routes/vision_llm_routes.py new file mode 100644 index 000000000..eddd5e367 --- /dev/null +++ b/surfsense_backend/app/routes/vision_llm_routes.py @@ -0,0 +1,295 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import config +from app.db import ( + Permission, + User, + VisionLLMConfig, + get_async_session, +) +from app.schemas import ( + GlobalVisionLLMConfigRead, + VisionLLMConfigCreate, + VisionLLMConfigRead, + VisionLLMConfigUpdate, +) +from app.services.vision_model_list_service import get_vision_model_list +from app.users import current_active_user +from app.utils.rbac import check_permission + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Vision Model Catalogue (from OpenRouter, filtered for image-input models) +# ============================================================================= + + +class VisionModelListItem(BaseModel): + value: str + label: str + provider: str + context_window: str | None = None + + +@router.get("/vision-models", response_model=list[VisionModelListItem]) +async def list_vision_models( + user: User = Depends(current_active_user), +): + """Return vision-capable models sourced from OpenRouter (filtered by image input).""" + try: + return await get_vision_model_list() + except Exception as e: + logger.exception("Failed to fetch vision model list") + raise HTTPException( + status_code=500, detail=f"Failed to fetch vision model list: {e!s}" + ) from e + + +# ============================================================================= +# Global Vision LLM Configs (from YAML) +# ============================================================================= + + +@router.get( + "/global-vision-llm-configs", + response_model=list[GlobalVisionLLMConfigRead], +) +async def get_global_vision_llm_configs( + user: User = Depends(current_active_user), +): + try: + global_configs = config.GLOBAL_VISION_LLM_CONFIGS + safe_configs = [] + + if global_configs and len(global_configs) > 0: + safe_configs.append( + { + "id": 0, + "name": "Auto (Fastest)", + "description": "Automatically routes across available vision LLM providers.", + "provider": "AUTO", + "custom_provider": None, + "model_name": "auto", + "api_base": None, + "api_version": None, + "litellm_params": {}, + "is_global": True, + "is_auto_mode": True, + } + ) + + for cfg in global_configs: + safe_configs.append( + { + "id": cfg.get("id"), + "name": cfg.get("name"), + "description": cfg.get("description"), + "provider": cfg.get("provider"), + "custom_provider": cfg.get("custom_provider"), + "model_name": cfg.get("model_name"), + "api_base": cfg.get("api_base") or None, + "api_version": cfg.get("api_version") or None, + "litellm_params": cfg.get("litellm_params", {}), + "is_global": True, + } + ) + + return safe_configs + except Exception as e: + logger.exception("Failed to fetch global vision LLM configs") + raise HTTPException( + status_code=500, detail=f"Failed to fetch configs: {e!s}" + ) from e + + +# ============================================================================= +# VisionLLMConfig CRUD +# ============================================================================= + + +@router.post("/vision-llm-configs", response_model=VisionLLMConfigRead) +async def create_vision_llm_config( + config_data: VisionLLMConfigCreate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + try: + await check_permission( + session, + user, + config_data.search_space_id, + Permission.VISION_CONFIGS_CREATE.value, + "You don't have permission to create vision LLM configs in this search space", + ) + + db_config = VisionLLMConfig(**config_data.model_dump(), user_id=user.id) + session.add(db_config) + await session.commit() + await session.refresh(db_config) + return db_config + + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.exception("Failed to create VisionLLMConfig") + raise HTTPException( + status_code=500, detail=f"Failed to create config: {e!s}" + ) from e + + +@router.get("/vision-llm-configs", response_model=list[VisionLLMConfigRead]) +async def list_vision_llm_configs( + search_space_id: int, + skip: int = 0, + limit: int = 100, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + try: + await check_permission( + session, + user, + search_space_id, + Permission.VISION_CONFIGS_READ.value, + "You don't have permission to view vision LLM configs in this search space", + ) + + result = await session.execute( + select(VisionLLMConfig) + .filter(VisionLLMConfig.search_space_id == search_space_id) + .order_by(VisionLLMConfig.created_at.desc()) + .offset(skip) + .limit(limit) + ) + return result.scalars().all() + + except HTTPException: + raise + except Exception as e: + logger.exception("Failed to list VisionLLMConfigs") + raise HTTPException( + status_code=500, detail=f"Failed to fetch configs: {e!s}" + ) from e + + +@router.get( + "/vision-llm-configs/{config_id}", response_model=VisionLLMConfigRead +) +async def get_vision_llm_config( + config_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + try: + result = await session.execute( + select(VisionLLMConfig).filter(VisionLLMConfig.id == config_id) + ) + db_config = result.scalars().first() + if not db_config: + raise HTTPException(status_code=404, detail="Config not found") + + await check_permission( + session, + user, + db_config.search_space_id, + Permission.VISION_CONFIGS_READ.value, + "You don't have permission to view vision LLM configs in this search space", + ) + return db_config + + except HTTPException: + raise + except Exception as e: + logger.exception("Failed to get VisionLLMConfig") + raise HTTPException( + status_code=500, detail=f"Failed to fetch config: {e!s}" + ) from e + + +@router.put( + "/vision-llm-configs/{config_id}", response_model=VisionLLMConfigRead +) +async def update_vision_llm_config( + config_id: int, + update_data: VisionLLMConfigUpdate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + try: + result = await session.execute( + select(VisionLLMConfig).filter(VisionLLMConfig.id == config_id) + ) + db_config = result.scalars().first() + if not db_config: + raise HTTPException(status_code=404, detail="Config not found") + + await check_permission( + session, + user, + db_config.search_space_id, + Permission.VISION_CONFIGS_CREATE.value, + "You don't have permission to update vision LLM configs in this search space", + ) + + for key, value in update_data.model_dump(exclude_unset=True).items(): + setattr(db_config, key, value) + + await session.commit() + await session.refresh(db_config) + return db_config + + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.exception("Failed to update VisionLLMConfig") + raise HTTPException( + status_code=500, detail=f"Failed to update config: {e!s}" + ) from e + + +@router.delete("/vision-llm-configs/{config_id}", response_model=dict) +async def delete_vision_llm_config( + config_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + try: + result = await session.execute( + select(VisionLLMConfig).filter(VisionLLMConfig.id == config_id) + ) + db_config = result.scalars().first() + if not db_config: + raise HTTPException(status_code=404, detail="Config not found") + + await check_permission( + session, + user, + db_config.search_space_id, + Permission.VISION_CONFIGS_DELETE.value, + "You don't have permission to delete vision LLM configs in this search space", + ) + + await session.delete(db_config) + await session.commit() + return { + "message": "Vision LLM config deleted successfully", + "id": config_id, + } + + except HTTPException: + raise + except Exception as e: + await session.rollback() + logger.exception("Failed to delete VisionLLMConfig") + raise HTTPException( + status_code=500, detail=f"Failed to delete config: {e!s}" + ) from e diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index b94a30c19..fdf34672b 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -125,6 +125,13 @@ from .video_presentations import ( VideoPresentationRead, VideoPresentationUpdate, ) +from .vision_llm import ( + GlobalVisionLLMConfigRead, + VisionLLMConfigCreate, + VisionLLMConfigPublic, + VisionLLMConfigRead, + VisionLLMConfigUpdate, +) __all__ = [ # Folder schemas @@ -163,6 +170,8 @@ __all__ = [ "FolderUpdate", "GlobalImageGenConfigRead", "GlobalNewLLMConfigRead", + # Vision LLM Config schemas + "GlobalVisionLLMConfigRead", "GoogleDriveIndexRequest", "GoogleDriveIndexingOptions", # Base schemas @@ -264,4 +273,8 @@ __all__ = [ "VideoPresentationCreate", "VideoPresentationRead", "VideoPresentationUpdate", + "VisionLLMConfigCreate", + "VisionLLMConfigPublic", + "VisionLLMConfigRead", + "VisionLLMConfigUpdate", ] diff --git a/surfsense_backend/app/schemas/new_llm_config.py b/surfsense_backend/app/schemas/new_llm_config.py index 6c76ca512..a466f2c99 100644 --- a/surfsense_backend/app/schemas/new_llm_config.py +++ b/surfsense_backend/app/schemas/new_llm_config.py @@ -182,8 +182,8 @@ class LLMPreferencesRead(BaseModel): image_generation_config_id: int | None = Field( None, description="ID of the image generation config to use" ) - vision_llm_id: int | None = Field( - None, description="ID of the LLM config to use for vision/screenshot analysis" + vision_llm_config_id: int | None = Field( + None, description="ID of the vision LLM config to use for vision/screenshot analysis" ) agent_llm: dict[str, Any] | None = Field( None, description="Full config for agent LLM" @@ -194,7 +194,7 @@ class LLMPreferencesRead(BaseModel): image_generation_config: dict[str, Any] | None = Field( None, description="Full config for image generation" ) - vision_llm: dict[str, Any] | None = Field( + vision_llm_config: dict[str, Any] | None = Field( None, description="Full config for vision LLM" ) @@ -213,6 +213,6 @@ class LLMPreferencesUpdate(BaseModel): image_generation_config_id: int | None = Field( None, description="ID of the image generation config to use" ) - vision_llm_id: int | None = Field( - None, description="ID of the LLM config to use for vision/screenshot analysis" + vision_llm_config_id: int | None = Field( + None, description="ID of the vision LLM config to use for vision/screenshot analysis" ) diff --git a/surfsense_backend/app/schemas/vision_llm.py b/surfsense_backend/app/schemas/vision_llm.py new file mode 100644 index 000000000..ab2e609dc --- /dev/null +++ b/surfsense_backend/app/schemas/vision_llm.py @@ -0,0 +1,75 @@ +import uuid +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from app.db import VisionProvider + + +class VisionLLMConfigBase(BaseModel): + name: str = Field(..., max_length=100) + description: str | None = Field(None, max_length=500) + provider: VisionProvider = Field(...) + custom_provider: str | None = Field(None, max_length=100) + model_name: str = Field(..., max_length=100) + api_key: str = Field(...) + api_base: str | None = Field(None, max_length=500) + api_version: str | None = Field(None, max_length=50) + litellm_params: dict[str, Any] | None = Field(default=None) + + +class VisionLLMConfigCreate(VisionLLMConfigBase): + search_space_id: int = Field(...) + + +class VisionLLMConfigUpdate(BaseModel): + name: str | None = Field(None, max_length=100) + description: str | None = Field(None, max_length=500) + provider: VisionProvider | None = None + custom_provider: str | None = Field(None, max_length=100) + model_name: str | None = Field(None, max_length=100) + api_key: str | None = None + api_base: str | None = Field(None, max_length=500) + api_version: str | None = Field(None, max_length=50) + litellm_params: dict[str, Any] | None = None + + +class VisionLLMConfigRead(VisionLLMConfigBase): + id: int + created_at: datetime + search_space_id: int + user_id: uuid.UUID + + model_config = ConfigDict(from_attributes=True) + + +class VisionLLMConfigPublic(BaseModel): + id: int + name: str + description: str | None = None + provider: VisionProvider + custom_provider: str | None = None + model_name: str + api_base: str | None = None + api_version: str | None = None + litellm_params: dict[str, Any] | None = None + created_at: datetime + search_space_id: int + user_id: uuid.UUID + + model_config = ConfigDict(from_attributes=True) + + +class GlobalVisionLLMConfigRead(BaseModel): + id: int = Field(...) + name: str + description: str | None = None + provider: str + custom_provider: str | None = None + model_name: str + api_base: str | None = None + api_version: str | None = None + litellm_params: dict[str, Any] | None = None + is_global: bool = True + is_auto_mode: bool = False diff --git a/surfsense_backend/app/services/llm_service.py b/surfsense_backend/app/services/llm_service.py index 7c0f9e7e3..e531aeabb 100644 --- a/surfsense_backend/app/services/llm_service.py +++ b/surfsense_backend/app/services/llm_service.py @@ -32,7 +32,6 @@ logger = logging.getLogger(__name__) class LLMRole: AGENT = "agent" # For agent/chat operations DOCUMENT_SUMMARY = "document_summary" # For document summarization - VISION = "vision" # For vision/screenshot analysis def get_global_llm_config(llm_config_id: int) -> dict | None: @@ -188,7 +187,7 @@ async def get_search_space_llm_instance( Args: session: Database session search_space_id: Search Space ID - role: LLM role ('agent', 'document_summary', or 'vision') + role: LLM role ('agent' or 'document_summary') Returns: ChatLiteLLM or ChatLiteLLMRouter instance, or None if not found @@ -210,8 +209,6 @@ async def get_search_space_llm_instance( llm_config_id = search_space.agent_llm_id elif role == LLMRole.DOCUMENT_SUMMARY: llm_config_id = search_space.document_summary_llm_id - elif role == LLMRole.VISION: - llm_config_id = search_space.vision_llm_id else: logger.error(f"Invalid LLM role: {role}") return None @@ -411,8 +408,118 @@ async def get_document_summary_llm( async def get_vision_llm( session: AsyncSession, search_space_id: int ) -> ChatLiteLLM | ChatLiteLLMRouter | None: - """Get the search space's vision LLM instance for screenshot analysis.""" - return await get_search_space_llm_instance(session, search_space_id, LLMRole.VISION) + """Get the search space's vision LLM instance for screenshot analysis. + + Resolves from the dedicated VisionLLMConfig system: + - Auto mode (ID 0): VisionLLMRouterService + - Global (negative ID): YAML configs + - DB (positive ID): VisionLLMConfig table + """ + from app.db import VisionLLMConfig + from app.services.vision_llm_router_service import ( + VISION_PROVIDER_MAP, + VisionLLMRouterService, + get_global_vision_llm_config, + is_vision_auto_mode, + ) + + try: + result = await session.execute( + select(SearchSpace).where(SearchSpace.id == search_space_id) + ) + search_space = result.scalars().first() + if not search_space: + logger.error(f"Search space {search_space_id} not found") + return None + + config_id = search_space.vision_llm_config_id + if config_id is None: + logger.error( + f"No vision LLM configured for search space {search_space_id}" + ) + return None + + if is_vision_auto_mode(config_id): + if not VisionLLMRouterService.is_initialized(): + logger.error( + "Vision Auto mode requested but Vision LLM Router not initialized" + ) + return None + try: + return ChatLiteLLMRouter( + router=VisionLLMRouterService.get_router(), + streaming=True, + ) + except Exception as e: + logger.error(f"Failed to create vision ChatLiteLLMRouter: {e}") + return None + + if config_id < 0: + global_cfg = get_global_vision_llm_config(config_id) + if not global_cfg: + logger.error(f"Global vision LLM config {config_id} not found") + return None + + if global_cfg.get("custom_provider"): + model_string = ( + f"{global_cfg['custom_provider']}/{global_cfg['model_name']}" + ) + else: + prefix = VISION_PROVIDER_MAP.get( + global_cfg["provider"].upper(), + global_cfg["provider"].lower(), + ) + model_string = f"{prefix}/{global_cfg['model_name']}" + + litellm_kwargs = { + "model": model_string, + "api_key": global_cfg["api_key"], + } + if global_cfg.get("api_base"): + litellm_kwargs["api_base"] = global_cfg["api_base"] + if global_cfg.get("litellm_params"): + litellm_kwargs.update(global_cfg["litellm_params"]) + + return ChatLiteLLM(**litellm_kwargs) + + result = await session.execute( + select(VisionLLMConfig).where( + VisionLLMConfig.id == config_id, + VisionLLMConfig.search_space_id == search_space_id, + ) + ) + vision_cfg = result.scalars().first() + if not vision_cfg: + logger.error( + f"Vision LLM config {config_id} not found in search space {search_space_id}" + ) + return None + + if vision_cfg.custom_provider: + model_string = f"{vision_cfg.custom_provider}/{vision_cfg.model_name}" + else: + prefix = VISION_PROVIDER_MAP.get( + vision_cfg.provider.value.upper(), + vision_cfg.provider.value.lower(), + ) + model_string = f"{prefix}/{vision_cfg.model_name}" + + litellm_kwargs = { + "model": model_string, + "api_key": vision_cfg.api_key, + } + if vision_cfg.api_base: + litellm_kwargs["api_base"] = vision_cfg.api_base + if vision_cfg.litellm_params: + litellm_kwargs.update(vision_cfg.litellm_params) + + return ChatLiteLLM(**litellm_kwargs) + + except Exception as e: + logger.error( + f"Error getting vision LLM for search space {search_space_id}: {e!s}" + ) + return None # Backward-compatible alias (LLM preferences are now per-search-space, not per-user) 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/app/services/vision_llm_router_service.py b/surfsense_backend/app/services/vision_llm_router_service.py new file mode 100644 index 000000000..0d782ab2b --- /dev/null +++ b/surfsense_backend/app/services/vision_llm_router_service.py @@ -0,0 +1,193 @@ +import logging +from typing import Any + +from litellm import Router + +logger = logging.getLogger(__name__) + +VISION_AUTO_MODE_ID = 0 + +VISION_PROVIDER_MAP = { + "OPENAI": "openai", + "ANTHROPIC": "anthropic", + "GOOGLE": "gemini", + "AZURE_OPENAI": "azure", + "VERTEX_AI": "vertex_ai", + "BEDROCK": "bedrock", + "XAI": "xai", + "OPENROUTER": "openrouter", + "OLLAMA": "ollama_chat", + "GROQ": "groq", + "TOGETHER_AI": "together_ai", + "FIREWORKS_AI": "fireworks_ai", + "DEEPSEEK": "openai", + "MISTRAL": "mistral", + "CUSTOM": "custom", +} + + +class VisionLLMRouterService: + _instance = None + _router: Router | None = None + _model_list: list[dict] = [] + _router_settings: dict = {} + _initialized: bool = False + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @classmethod + def get_instance(cls) -> "VisionLLMRouterService": + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @classmethod + def initialize( + cls, + global_configs: list[dict], + router_settings: dict | None = None, + ) -> None: + instance = cls.get_instance() + + if instance._initialized: + logger.debug("Vision LLM Router already initialized, skipping") + return + + model_list = [] + for config in global_configs: + deployment = cls._config_to_deployment(config) + if deployment: + model_list.append(deployment) + + if not model_list: + logger.warning( + "No valid vision LLM configs found for router initialization" + ) + return + + instance._model_list = model_list + instance._router_settings = router_settings or {} + + default_settings = { + "routing_strategy": "usage-based-routing", + "num_retries": 3, + "allowed_fails": 3, + "cooldown_time": 60, + "retry_after": 5, + } + + final_settings = {**default_settings, **instance._router_settings} + + try: + instance._router = Router( + model_list=model_list, + routing_strategy=final_settings.get( + "routing_strategy", "usage-based-routing" + ), + num_retries=final_settings.get("num_retries", 3), + allowed_fails=final_settings.get("allowed_fails", 3), + cooldown_time=final_settings.get("cooldown_time", 60), + set_verbose=False, + ) + instance._initialized = True + logger.info( + "Vision LLM Router initialized with %d deployments, strategy: %s", + len(model_list), + final_settings.get("routing_strategy"), + ) + except Exception as e: + logger.error(f"Failed to initialize Vision LLM Router: {e}") + instance._router = None + + @classmethod + def _config_to_deployment(cls, config: dict) -> dict | None: + try: + if not config.get("model_name") or not config.get("api_key"): + return None + + if config.get("custom_provider"): + model_string = f"{config['custom_provider']}/{config['model_name']}" + else: + provider = config.get("provider", "").upper() + provider_prefix = VISION_PROVIDER_MAP.get(provider, provider.lower()) + model_string = f"{provider_prefix}/{config['model_name']}" + + litellm_params: dict[str, Any] = { + "model": model_string, + "api_key": config.get("api_key"), + } + + if config.get("api_base"): + litellm_params["api_base"] = config["api_base"] + + if config.get("api_version"): + litellm_params["api_version"] = config["api_version"] + + if config.get("litellm_params"): + litellm_params.update(config["litellm_params"]) + + deployment: dict[str, Any] = { + "model_name": "auto", + "litellm_params": litellm_params, + } + + if config.get("rpm"): + deployment["rpm"] = config["rpm"] + if config.get("tpm"): + deployment["tpm"] = config["tpm"] + + return deployment + + except Exception as e: + logger.warning(f"Failed to convert vision config to deployment: {e}") + return None + + @classmethod + def get_router(cls) -> Router | None: + instance = cls.get_instance() + return instance._router + + @classmethod + def is_initialized(cls) -> bool: + instance = cls.get_instance() + return instance._initialized and instance._router is not None + + @classmethod + def get_model_count(cls) -> int: + instance = cls.get_instance() + return len(instance._model_list) + + +def is_vision_auto_mode(config_id: int | None) -> bool: + return config_id == VISION_AUTO_MODE_ID + + +def build_vision_model_string( + provider: str, model_name: str, custom_provider: str | None +) -> str: + if custom_provider: + return f"{custom_provider}/{model_name}" + prefix = VISION_PROVIDER_MAP.get(provider.upper(), provider.lower()) + return f"{prefix}/{model_name}" + + +def get_global_vision_llm_config(config_id: int) -> dict | None: + from app.config import config + + if config_id == VISION_AUTO_MODE_ID: + return { + "id": VISION_AUTO_MODE_ID, + "name": "Auto (Fastest)", + "provider": "AUTO", + "model_name": "auto", + "is_auto_mode": True, + } + if config_id > 0: + return None + for cfg in config.GLOBAL_VISION_LLM_CONFIGS: + if cfg.get("id") == config_id: + return cfg + return None diff --git a/surfsense_backend/app/services/vision_model_list_service.py b/surfsense_backend/app/services/vision_model_list_service.py new file mode 100644 index 000000000..09893dd06 --- /dev/null +++ b/surfsense_backend/app/services/vision_model_list_service.py @@ -0,0 +1,132 @@ +""" +Service for fetching and caching the vision-capable model list. + +Reuses the same OpenRouter public API and local fallback as the LLM model +list service, but filters for models that accept image input. +""" + +import json +import logging +import time +from pathlib import Path + +import httpx + +logger = logging.getLogger(__name__) + +OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models" +FALLBACK_FILE = Path(__file__).parent.parent / "config" / "vision_model_list_fallback.json" +CACHE_TTL_SECONDS = 86400 # 24 hours + +_cache: list[dict] | None = None +_cache_timestamp: float = 0 + +OPENROUTER_SLUG_TO_VISION_PROVIDER: dict[str, str] = { + "openai": "OPENAI", + "anthropic": "ANTHROPIC", + "google": "GOOGLE", + "mistralai": "MISTRAL", + "x-ai": "XAI", +} + + +def _format_context_length(length: int | None) -> str | None: + if not length: + return None + if length >= 1_000_000: + return f"{length / 1_000_000:g}M" + if length >= 1_000: + return f"{length / 1_000:g}K" + return str(length) + + +async def _fetch_from_openrouter() -> list[dict] | None: + try: + async with httpx.AsyncClient(timeout=15) as client: + response = await client.get(OPENROUTER_API_URL) + response.raise_for_status() + data = response.json() + return data.get("data", []) + except Exception as e: + logger.warning("Failed to fetch from OpenRouter API for vision models: %s", e) + return None + + +def _load_fallback() -> list[dict]: + try: + with open(FALLBACK_FILE, encoding="utf-8") as f: + return json.load(f) + except Exception as e: + logger.error("Failed to load vision model fallback list: %s", e) + return [] + + +def _is_vision_model(model: dict) -> bool: + """Return True if the model accepts image input and outputs text.""" + arch = model.get("architecture", {}) + input_mods = arch.get("input_modalities", []) + output_mods = arch.get("output_modalities", []) + return "image" in input_mods and "text" in output_mods + + +def _process_vision_models(raw_models: list[dict]) -> list[dict]: + processed: list[dict] = [] + + for model in raw_models: + model_id: str = model.get("id", "") + name: str = model.get("name", "") + context_length = model.get("context_length") + + if "/" not in model_id: + continue + + if not _is_vision_model(model): + continue + + provider_slug, model_name = model_id.split("/", 1) + context_window = _format_context_length(context_length) + + processed.append( + { + "value": model_id, + "label": name, + "provider": "OPENROUTER", + "context_window": context_window, + } + ) + + native_provider = OPENROUTER_SLUG_TO_VISION_PROVIDER.get(provider_slug) + if native_provider: + if native_provider == "GOOGLE" and not model_name.startswith("gemini-"): + continue + + processed.append( + { + "value": model_name, + "label": name, + "provider": native_provider, + "context_window": context_window, + } + ) + + return processed + + +async def get_vision_model_list() -> list[dict]: + global _cache, _cache_timestamp + + if _cache is not None and (time.time() - _cache_timestamp) < CACHE_TTL_SECONDS: + return _cache + + raw_models = await _fetch_from_openrouter() + + if raw_models is None: + logger.info("Using fallback vision model list") + return _load_fallback() + + processed = _process_vision_models(raw_models) + + _cache = processed + _cache_timestamp = time.time() + + return processed diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml index 48b219776..7010ac56f 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", @@ -76,6 +74,8 @@ dependencies = [ "deepagents>=0.4.12", "stripe>=15.0.0", "azure-ai-documentintelligence>=1.0.2", + "litellm>=1.83.0", + "langchain-litellm>=0.6.4", ] [dependency-groups] diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock index 1bf32489e..72743b807 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, upload-time = "2026-01-03T17:33:05.204Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } 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, upload-time = "2026-01-03T17:30:14.23Z" }, - { 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, upload-time = "2026-01-03T17:30:15.96Z" }, - { 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, upload-time = "2026-01-03T17:30:17.431Z" }, - { 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, upload-time = "2026-01-03T17:30:19.422Z" }, - { 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, upload-time = "2026-01-03T17:30:21.756Z" }, - { 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, upload-time = "2026-01-03T17:30:23.932Z" }, - { 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, upload-time = "2026-01-03T17:30:26Z" }, - { 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, upload-time = "2026-01-03T17:30:27.554Z" }, - { 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, upload-time = "2026-01-03T17:30:29.254Z" }, - { 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, upload-time = "2026-01-03T17:30:31.033Z" }, - { 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, upload-time = "2026-01-03T17:30:32.703Z" }, - { 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, upload-time = "2026-01-03T17:30:34.695Z" }, - { 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, upload-time = "2026-01-03T17:30:36.864Z" }, - { 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, upload-time = "2026-01-03T17:30:39.433Z" }, - { 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, upload-time = "2026-01-03T17:30:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, - { 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, upload-time = "2026-01-03T17:30:45.832Z" }, - { 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, upload-time = "2026-01-03T17:30:47.466Z" }, - { 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, upload-time = "2026-01-03T17:30:49.373Z" }, - { 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, upload-time = "2026-01-03T17:30:50.974Z" }, - { 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, upload-time = "2026-01-03T17:30:52.729Z" }, - { 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, upload-time = "2026-01-03T17:30:54.537Z" }, - { 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, upload-time = "2026-01-03T17:30:56.512Z" }, - { 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, upload-time = "2026-01-03T17:30:58.256Z" }, - { 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, upload-time = "2026-01-03T17:31:00.445Z" }, - { 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, upload-time = "2026-01-03T17:31:03.024Z" }, - { 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, upload-time = "2026-01-03T17:31:04.842Z" }, - { 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, upload-time = "2026-01-03T17:31:06.868Z" }, - { 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, upload-time = "2026-01-03T17:31:08.958Z" }, - { 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, upload-time = "2026-01-03T17:31:10.676Z" }, - { 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, upload-time = "2026-01-03T17:31:12.575Z" }, - { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, - { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, - { 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, upload-time = "2026-01-03T17:31:17.909Z" }, - { 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, upload-time = "2026-01-03T17:31:19.919Z" }, - { 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, upload-time = "2026-01-03T17:31:21.636Z" }, - { 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, upload-time = "2026-01-03T17:31:23.296Z" }, - { 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, upload-time = "2026-01-03T17:31:25.334Z" }, - { 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, upload-time = "2026-01-03T17:31:27.394Z" }, - { 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, upload-time = "2026-01-03T17:31:29.238Z" }, - { 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, upload-time = "2026-01-03T17:31:31.053Z" }, - { 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, upload-time = "2026-01-03T17:31:32.87Z" }, - { 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, upload-time = "2026-01-03T17:31:34.76Z" }, - { 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, upload-time = "2026-01-03T17:31:36.699Z" }, - { 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, upload-time = "2026-01-03T17:31:38.622Z" }, - { 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, upload-time = "2026-01-03T17:31:40.57Z" }, - { 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, upload-time = "2026-01-03T17:31:42.857Z" }, - { 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, upload-time = "2026-01-03T17:31:44.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, - { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, - { 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, upload-time = "2026-01-03T17:31:51.134Z" }, - { 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, upload-time = "2026-01-03T17:31:52.85Z" }, - { 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, upload-time = "2026-01-03T17:31:54.91Z" }, - { 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, upload-time = "2026-01-03T17:31:56.733Z" }, - { 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, upload-time = "2026-01-03T17:31:58.65Z" }, - { 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, upload-time = "2026-01-03T17:32:00.989Z" }, - { 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, upload-time = "2026-01-03T17:32:03.122Z" }, - { 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, upload-time = "2026-01-03T17:32:05.255Z" }, - { 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, upload-time = "2026-01-03T17:32:07.607Z" }, - { 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, upload-time = "2026-01-03T17:32:09.59Z" }, - { 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, upload-time = "2026-01-03T17:32:11.445Z" }, - { 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, upload-time = "2026-01-03T17:32:13.705Z" }, - { 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, upload-time = "2026-01-03T17:32:15.965Z" }, - { 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, upload-time = "2026-01-03T17:32:18.219Z" }, - { 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, upload-time = "2026-01-03T17:32:20.25Z" }, - { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, + { 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, upload-time = "2026-03-31T21:57:36.319Z" }, + { 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, upload-time = "2026-03-31T21:57:38.236Z" }, + { 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, upload-time = "2026-03-31T21:57:39.923Z" }, + { 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, upload-time = "2026-03-31T21:57:41.938Z" }, + { 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, upload-time = "2026-03-31T21:57:43.904Z" }, + { 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, upload-time = "2026-03-31T21:57:46.285Z" }, + { 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, upload-time = "2026-03-31T21:57:48.734Z" }, + { 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, upload-time = "2026-03-31T21:57:51.171Z" }, + { 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, upload-time = "2026-03-31T21:57:53.326Z" }, + { 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, upload-time = "2026-03-31T21:57:55.385Z" }, + { 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, upload-time = "2026-03-31T21:57:57.341Z" }, + { 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, upload-time = "2026-03-31T21:57:59.626Z" }, + { 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, upload-time = "2026-03-31T21:58:01.972Z" }, + { 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, upload-time = "2026-03-31T21:58:04.007Z" }, + { 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, upload-time = "2026-03-31T21:58:06.337Z" }, + { url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534, upload-time = "2026-03-31T21:58:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446, upload-time = "2026-03-31T21:58:10.945Z" }, + { 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, upload-time = "2026-03-31T21:58:13.155Z" }, + { 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, upload-time = "2026-03-31T21:58:15.073Z" }, + { 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, upload-time = "2026-03-31T21:58:17.009Z" }, + { 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, upload-time = "2026-03-31T21:58:18.925Z" }, + { 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, upload-time = "2026-03-31T21:58:21.094Z" }, + { 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, upload-time = "2026-03-31T21:58:23.159Z" }, + { 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, upload-time = "2026-03-31T21:58:25.59Z" }, + { 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, upload-time = "2026-03-31T21:58:27.624Z" }, + { 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, upload-time = "2026-03-31T21:58:29.918Z" }, + { 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, upload-time = "2026-03-31T21:58:32.214Z" }, + { 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, upload-time = "2026-03-31T21:58:34.728Z" }, + { 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, upload-time = "2026-03-31T21:58:36.909Z" }, + { 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, upload-time = "2026-03-31T21:58:39.38Z" }, + { 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, upload-time = "2026-03-31T21:58:41.476Z" }, + { 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, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { 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, upload-time = "2026-03-31T21:58:50.229Z" }, + { 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, upload-time = "2026-03-31T21:58:52.232Z" }, + { 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, upload-time = "2026-03-31T21:58:54.566Z" }, + { 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, upload-time = "2026-03-31T21:58:56.864Z" }, + { 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, upload-time = "2026-03-31T21:58:59.072Z" }, + { 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, upload-time = "2026-03-31T21:59:01.776Z" }, + { 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, upload-time = "2026-03-31T21:59:04.568Z" }, + { 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, upload-time = "2026-03-31T21:59:07.221Z" }, + { 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, upload-time = "2026-03-31T21:59:09.484Z" }, + { 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, upload-time = "2026-03-31T21:59:12.068Z" }, + { 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, upload-time = "2026-03-31T21:59:14.552Z" }, + { 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, upload-time = "2026-03-31T21:59:17.068Z" }, + { 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, upload-time = "2026-03-31T21:59:19.541Z" }, + { 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, upload-time = "2026-03-31T21:59:22.311Z" }, + { 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, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { 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, upload-time = "2026-03-31T21:59:31.547Z" }, + { 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, upload-time = "2026-03-31T21:59:34.098Z" }, + { 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, upload-time = "2026-03-31T21:59:36.497Z" }, + { 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, upload-time = "2026-03-31T21:59:38.723Z" }, + { 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, upload-time = "2026-03-31T21:59:41.187Z" }, + { 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, upload-time = "2026-03-31T21:59:43.84Z" }, + { 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, upload-time = "2026-03-31T21:59:46.187Z" }, + { 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, upload-time = "2026-03-31T21:59:48.656Z" }, + { 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, upload-time = "2026-03-31T21:59:51.284Z" }, + { 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, upload-time = "2026-03-31T21:59:53.753Z" }, + { 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, upload-time = "2026-03-31T21:59:56.239Z" }, + { 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, upload-time = "2026-03-31T21:59:59.103Z" }, + { 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, upload-time = "2026-03-31T22:00:02.116Z" }, + { 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, upload-time = "2026-03-31T22:00:04.756Z" }, + { 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, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, ] [[package]] @@ -1037,14 +1037,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, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, ] [[package]] @@ -2998,14 +2998,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, upload-time = "2025-12-21T10:00:19.278Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, ] [[package]] @@ -3240,7 +3240,7 @@ wheels = [ [[package]] name = "jsonschema" -version = "4.26.0" +version = "4.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -3248,9 +3248,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, upload-time = "2026-01-07T13:41:07.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778, upload-time = "2024-07-08T18:40:05.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, + { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" }, ] [[package]] @@ -3555,7 +3555,7 @@ wheels = [ [[package]] name = "langchain-litellm" -version = "0.6.2" +version = "0.6.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, @@ -3563,9 +3563,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, upload-time = "2026-03-24T17:16:45.14Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/37/ccc1f284a42900ca5b267a50da8e50145e9f264b32ee955ce91aa360d188/langchain_litellm-0.6.4.tar.gz", hash = "sha256:663281db392b3de1f07f891d0f80f9d4b26c0f0d2abbf854ef9b186d99c309ee", size = 339457, upload-time = "2026-04-03T16:56:47.886Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/14/ad857a3f56fa4ea0879ac9d6ee5248c883663d0bad94bf8741e1ab6ab200/langchain_litellm-0.6.2-py3-none-any.whl", hash = "sha256:98af79dbcdea4b492e9601351bc5fd15fdd368e021183b8540f0d0b6b6b1589c", size = 24865, upload-time = "2026-03-24T17:16:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/25c50bbad7a05106c7af65557e165d6cb6159c90854dae61de59debe735d/langchain_litellm-0.6.4-py3-none-any.whl", hash = "sha256:60f4e37be1a47dc88f94fac7085675ef8fa04bba92f48735792d82f492120744", size = 26360, upload-time = "2026-04-03T16:56:46.76Z" }, ] [[package]] @@ -3731,7 +3731,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.82.6" +version = "1.83.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -3747,9 +3747,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, upload-time = "2026-03-22T06:36:00.452Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/c4/30469c06ae7437a4406bc11e3c433cfd380a6771068cca15ea918dcd158f/litellm-1.83.4.tar.gz", hash = "sha256:6458d2030a41229460b321adee00517a91dbd8e63213cc953d355cb41d16f2d4", size = 17733899, upload-time = "2026-04-07T04:33:47.445Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595, upload-time = "2026-03-22T06:35:56.795Z" }, + { url = "https://files.pythonhosted.org/packages/b8/bd/df19d3f8f6654535ee343a341fd921f81c411abf601a53e3eaef58129b02/litellm-1.83.4-py3-none-any.whl", hash = "sha256:17d7b4d48d47aca988ea4f762ddda5e7bd72cda3270192b22813d0330869d7b4", size = 16015555, upload-time = "2026-04-07T04:33:44.268Z" }, ] [[package]] @@ -6797,11 +6797,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, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115, upload-time = "2024-01-23T06:33:00.505Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" }, ] [[package]] @@ -8082,12 +8082,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/.env b/surfsense_desktop/.env index d053aac97..40e151c10 100644 --- a/surfsense_desktop/.env +++ b/surfsense_desktop/.env @@ -3,4 +3,8 @@ # The hosted web frontend URL. Used to intercept OAuth redirects and keep them # inside the desktop app. Set to your production frontend domain. -HOSTED_FRONTEND_URL=https://surfsense.net +HOSTED_FRONTEND_URL=https://surfsense.com + +# PostHog analytics (leave empty to disable) +POSTHOG_KEY= +POSTHOG_HOST=https://assets.surfsense.com 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..634783e47 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", @@ -14,7 +14,11 @@ "typecheck": "tsc --noEmit", "postinstall": "electron-rebuild" }, - "author": "MODSetter", + "homepage": "https://github.com/MODSetter/SurfSense", + "author": { + "name": "MODSetter", + "email": "rohan@surfsense.com" + }, "license": "MIT", "packageManager": "pnpm@10.24.0", "devDependencies": { @@ -34,6 +38,8 @@ "electron-store": "^11.0.2", "electron-updater": "^6.8.3", "get-port-please": "^3.2.0", - "node-mac-permissions": "^2.5.0" + "node-mac-permissions": "^2.5.0", + "node-machine-id": "^1.1.12", + "posthog-node": "^5.29.0" } } diff --git a/surfsense_desktop/pnpm-lock.yaml b/surfsense_desktop/pnpm-lock.yaml index e1df34fb2..e7b84cc01 100644 --- a/surfsense_desktop/pnpm-lock.yaml +++ b/surfsense_desktop/pnpm-lock.yaml @@ -26,6 +26,12 @@ importers: node-mac-permissions: specifier: ^2.5.0 version: 2.5.0 + node-machine-id: + specifier: ^1.1.12 + version: 1.1.12 + posthog-node: + specifier: ^5.29.0 + version: 5.29.0(rxjs@7.8.2) devDependencies: '@electron/rebuild': specifier: ^4.0.3 @@ -308,6 +314,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@posthog/core@1.25.0': + resolution: {integrity: sha512-XKaHvRFIIN7Dw84r1eKimV1rl9DS+9XMCPPZ7P3+l8fE+rDsmumebiTFsY+q40bVXflcGW9wB+57LH0lvcGmhw==} + '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -1194,6 +1203,9 @@ packages: resolution: {integrity: sha512-zR8SVCaN3WqV1xwWd04XVAdzm3UTdjbxciLrZtB0Cc7F2Kd34AJfhPD4hm1HU0YH3oGUZO4X9OBLY5ijSTHsGw==} os: [darwin] + node-machine-id@1.1.12: + resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==} + nopt@8.1.0: resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} engines: {node: ^18.17.0 || >=20.5.0} @@ -1263,6 +1275,15 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + posthog-node@5.29.0: + resolution: {integrity: sha512-po7N55haSKxV8VOulkBZJja938yILShl6+fFjoUV3iQgOBCg4Muu615/xRg8mpNiz+UASvL0EEiGvIxdhXfj6Q==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + postject@1.0.0-alpha.6: resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} engines: {node: '>=14.0.0'} @@ -1876,6 +1897,8 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@posthog/core@1.25.0': {} + '@sindresorhus/is@4.6.0': {} '@standard-schema/spec@1.1.0': {} @@ -2940,6 +2963,8 @@ snapshots: bindings: 1.5.0 node-addon-api: 7.1.1 + node-machine-id@1.1.12: {} + nopt@8.1.0: dependencies: abbrev: 3.0.1 @@ -3002,6 +3027,12 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + posthog-node@5.29.0(rxjs@7.8.2): + dependencies: + '@posthog/core': 1.25.0 + optionalDependencies: + rxjs: 7.8.2 + postject@1.0.0-alpha.6: dependencies: commander: 9.5.0 diff --git a/surfsense_desktop/scripts/build-electron.mjs b/surfsense_desktop/scripts/build-electron.mjs index 9f507ea37..90d76ef7a 100644 --- a/surfsense_desktop/scripts/build-electron.mjs +++ b/surfsense_desktop/scripts/build-electron.mjs @@ -111,6 +111,12 @@ async function buildElectron() { 'process.env.HOSTED_FRONTEND_URL': JSON.stringify( process.env.HOSTED_FRONTEND_URL || desktopEnv.HOSTED_FRONTEND_URL || 'https://surfsense.net' ), + 'process.env.POSTHOG_KEY': JSON.stringify( + process.env.POSTHOG_KEY || desktopEnv.POSTHOG_KEY || '' + ), + 'process.env.POSTHOG_HOST': JSON.stringify( + process.env.POSTHOG_HOST || desktopEnv.POSTHOG_HOST || 'https://assets.surfsense.com' + ), }, }; 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..231553f9a 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,8 @@ 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'; +import { initAnalytics, shutdownAnalytics, trackEvent } from './modules/analytics'; registerGlobalErrorHandlers(); @@ -19,6 +23,8 @@ if (!setupDeepLinks()) { registerIpcHandlers(); app.whenReady().then(async () => { + initAnalytics(); + trackEvent('desktop_app_launched'); setupMenu(); try { await startNextServer(); @@ -28,29 +34,54 @@ 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('will-quit', () => { +app.on('before-quit', () => { + isQuitting = true; +}); + +let didCleanup = false; +app.on('will-quit', async (e) => { + if (didCleanup) return; + didCleanup = true; + e.preventDefault(); unregisterQuickAsk(); unregisterAutocomplete(); unregisterFolderWatcher(); + destroyTray(); + await shutdownAnalytics(); + app.exit(); }); 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/analytics.ts b/surfsense_desktop/src/modules/analytics.ts new file mode 100644 index 000000000..0bbcb3026 --- /dev/null +++ b/surfsense_desktop/src/modules/analytics.ts @@ -0,0 +1,50 @@ +import { PostHog } from 'posthog-node'; +import { machineIdSync } from 'node-machine-id'; +import { app } from 'electron'; + +let client: PostHog | null = null; +let distinctId = ''; + +export function initAnalytics(): void { + const key = process.env.POSTHOG_KEY; + if (!key) return; + + try { + distinctId = machineIdSync(true); + } catch { + return; + } + + client = new PostHog(key, { + host: process.env.POSTHOG_HOST || 'https://assets.surfsense.com', + flushAt: 20, + flushInterval: 10000, + }); +} + +export function trackEvent(event: string, properties?: Record): void { + if (!client) return; + + try { + client.capture({ + distinctId, + event, + properties: { + platform: 'desktop', + app_version: app.getVersion(), + os: process.platform, + ...properties, + }, + }); + } catch { + // Analytics should never break the app + } +} + +export async function shutdownAnalytics(): Promise { + if (!client) return; + + const timeout = new Promise((resolve) => setTimeout(resolve, 3000)); + await Promise.race([client.shutdown(), timeout]); + client = null; +} diff --git a/surfsense_desktop/src/modules/autocomplete/index.ts b/surfsense_desktop/src/modules/autocomplete/index.ts index 01a4cf913..d4eb727fd 100644 --- a/surfsense_desktop/src/modules/autocomplete/index.ts +++ b/surfsense_desktop/src/modules/autocomplete/index.ts @@ -2,16 +2,16 @@ 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'; +import { trackEvent } from '../analytics'; -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 +37,12 @@ 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; + trackEvent('desktop_autocomplete_triggered', { search_space_id: searchSpaceId }); const cursor = screen.getCursorScreenPoint(); const win = createSuggestionWindow(cursor.x, cursor.y); @@ -91,11 +82,18 @@ 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) => { + trackEvent('desktop_autocomplete_accepted'); await acceptAndInject(text); }); ipcMain.handle(IPC_CHANNELS.DISMISS_SUGGESTION, () => { + trackEvent('desktop_autocomplete_dismissed'); destroySuggestion(); }); ipcMain.handle(IPC_CHANNELS.SET_AUTOCOMPLETE_ENABLED, (_event, enabled: boolean) => { @@ -107,26 +105,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..b31ae1bcd 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,13 +1,17 @@ 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'; +import { trackEvent } from './analytics'; -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 +56,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}?quickAssist=true`); quickAskWindow.once('ready-to-show', () => { quickAskWindow?.show(); @@ -77,29 +83,55 @@ 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; + pendingMode = 'quick-assist'; + 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)'); + trackEvent('desktop_quick_ask_opened', { has_selected_text: !!selected }); + openQuickAsk(text); +} + +let ipcRegistered = false; + +function registerIpcHandlers(): void { + if (ipcRegistered) return; + ipcRegistered = true; + ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => { const text = pendingText; pendingText = ''; @@ -122,6 +154,7 @@ export function registerQuickAsk(): void { if (!checkAccessibilityPermission()) return; + trackEvent('desktop_quick_ask_replaced'); clipboard.writeText(text); destroyQuickAsk(); @@ -136,6 +169,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/.env.example b/surfsense_web/.env.example index b674d8e9b..b448c1f71 100644 --- a/surfsense_web/.env.example +++ b/surfsense_web/.env.example @@ -7,4 +7,7 @@ NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848 DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres # Deployment mode (optional) -NEXT_PUBLIC_DEPLOYMENT_MODE="self-hosted" or "cloud" \ No newline at end of file +NEXT_PUBLIC_DEPLOYMENT_MODE="self-hosted" or "cloud" + +# PostHog analytics (optional, leave empty to disable) +NEXT_PUBLIC_POSTHOG_KEY= \ No newline at end of file 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..eceb46231 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,19 @@ 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..c3f457f96 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,71 @@ "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 type { SearchSpace } from "@/contracts/types/search-space.types"; +import { useElectronAPI } from "@/hooks/use-platform"; +import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; 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 +85,120 @@ 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 +207,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..8f68d20c1 --- /dev/null +++ b/surfsense_web/app/desktop/login/page.tsx @@ -0,0 +1,282 @@ +"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 +
+ ); + } return (
- {error} + {error.message}
); } - if (isLoading && !suggestion) { + const showLoading = isLoading && options.length === 0; + + if (showLoading) { return (
-
- - - +
+ {steps.length === 0 && ( +
+ + Preparing… +
+ )} + {steps.length > 0 && ( +
+ {steps.map((step) => ( +
+ + + {step.title} + {step.items.length > 0 && ( + · {step.items[0]} + )} + +
+ ))} +
+ )}
); } - const handleAccept = () => { - if (suggestion) { - window.electronAPI?.acceptSuggestion?.(suggestion); - } + const handleSelect = (text: string) => { + api?.acceptSuggestion?.(text); }; const handleDismiss = () => { - window.electronAPI?.dismissSuggestion?.(); + api?.dismissSuggestion?.(); }; - if (!suggestion) return null; + const TRUNCATE_LENGTH = 120; + + if (options.length === 0) { + return ( +
+ No suggestions available. +
+ ); + } return (
-

{suggestion}

+
+ {options.map((option, index) => { + const isExpanded = expandedOption === index; + const needsTruncation = option.length > TRUNCATE_LENGTH; + const displayText = + needsTruncation && !isExpanded ? option.slice(0, TRUNCATE_LENGTH) + "…" : option; + + return ( +
handleSelect(option)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSelect(option); + }} + > + {index + 1} + {displayText} + {needsTruncation && ( + + )} +
+ ); + })} +
- + + )} ); 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 edf1ef84a..2720e7135 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..c872afaf1 --- /dev/null +++ b/surfsense_web/components/desktop/shortcut-recorder.tsx @@ -0,0 +1,163 @@ +"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) => ( - - ))} +
+