mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +02:00
Merge pull request #1156 from MODSetter/dev_mod
refactor: streamlined desktop app
This commit is contained in:
commit
a05bb4ae0c
39 changed files with 2118 additions and 895 deletions
11
surfsense_backend/app/agents/autocomplete/__init__.py
Normal file
11
surfsense_backend/app/agents/autocomplete/__init__.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
442
surfsense_backend/app/agents/autocomplete/autocomplete_agent.py
Normal file
442
surfsense_backend/app/agents/autocomplete/autocomplete_agent.py
Normal file
|
|
@ -0,0 +1,442 @@
|
||||||
|
"""Vision autocomplete agent with scoped filesystem exploration.
|
||||||
|
|
||||||
|
Converts the stateless single-shot vision autocomplete into an agent that
|
||||||
|
seeds a virtual filesystem from KB search results and lets the vision LLM
|
||||||
|
explore documents via ``ls``, ``read_file``, ``glob``, ``grep``, etc.
|
||||||
|
before generating the final completion.
|
||||||
|
|
||||||
|
Performance: KB search and agent graph compilation run in parallel so
|
||||||
|
the only sequential latency is KB-search (or agent compile, whichever is
|
||||||
|
slower) + the agent's LLM turns. There is no separate "query extraction"
|
||||||
|
LLM call — the window title is used directly as the KB search query.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from deepagents.graph import BASE_AGENT_PROMPT
|
||||||
|
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
|
||||||
|
from langchain.agents import create_agent
|
||||||
|
from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware
|
||||||
|
from langchain_core.language_models import BaseChatModel
|
||||||
|
from langchain_core.messages import AIMessage, ToolMessage
|
||||||
|
|
||||||
|
from app.agents.new_chat.middleware.filesystem import SurfSenseFilesystemMiddleware
|
||||||
|
from app.agents.new_chat.middleware.knowledge_search import (
|
||||||
|
build_scoped_filesystem,
|
||||||
|
search_knowledge_base,
|
||||||
|
)
|
||||||
|
from app.services.new_streaming_service import VercelStreamingService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KB_TOP_K = 10
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# System prompt
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
AUTOCOMPLETE_SYSTEM_PROMPT = """You are a smart writing assistant that analyzes the user's screen to draft or complete text.
|
||||||
|
|
||||||
|
You will receive a screenshot of the user's screen. Your PRIMARY source of truth is the screenshot itself — the visual context determines what to write.
|
||||||
|
|
||||||
|
Your job:
|
||||||
|
1. Analyze the ENTIRE screenshot to understand what the user is working on (email thread, chat conversation, document, code editor, form, etc.).
|
||||||
|
2. Identify the text area where the user will type.
|
||||||
|
3. Generate the text the user most likely wants to write based on the visual context.
|
||||||
|
|
||||||
|
You also have access to the user's knowledge base documents via filesystem tools. However:
|
||||||
|
- ONLY consult the knowledge base if the screenshot clearly involves a topic where your KB documents are DIRECTLY relevant (e.g., the user is writing about a specific project/topic that matches a document title).
|
||||||
|
- Do NOT explore documents just because they exist. Most autocomplete requests can be answered purely from the screenshot.
|
||||||
|
- If you do read a document, only incorporate information that is 100% relevant to what the user is typing RIGHT NOW. Do not add extra details, background, or tangential information from the KB.
|
||||||
|
- Keep your output SHORT — autocomplete should feel like a natural continuation, not an essay.
|
||||||
|
|
||||||
|
Key behavior:
|
||||||
|
- If the text area is EMPTY, draft a concise response or message based on what you see on screen (e.g., reply to an email, respond to a chat message, continue a document).
|
||||||
|
- If the text area already has text, continue it naturally — typically just a sentence or two.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Output ONLY the text to be inserted. No quotes, no explanations, no meta-commentary.
|
||||||
|
- Be CONCISE. Prefer a single paragraph or a few sentences. Autocomplete is a quick assist, not a full draft.
|
||||||
|
- Match the tone and formality of the surrounding context.
|
||||||
|
- If the screen shows code, write code. If it shows a casual chat, be casual. If it shows a formal email, be formal.
|
||||||
|
- Do NOT describe the screenshot or explain your reasoning.
|
||||||
|
- Do NOT cite or reference documents explicitly — just let the knowledge inform your writing naturally.
|
||||||
|
- If you cannot determine what to write, output nothing.
|
||||||
|
|
||||||
|
## Filesystem Tools `ls`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`
|
||||||
|
|
||||||
|
All file paths must start with a `/`.
|
||||||
|
- ls: list files and directories at a given path.
|
||||||
|
- read_file: read a file from the filesystem.
|
||||||
|
- write_file: create a temporary file in the session (not persisted).
|
||||||
|
- edit_file: edit a file in the session (not persisted for /documents/ files).
|
||||||
|
- glob: find files matching a pattern (e.g., "**/*.xml").
|
||||||
|
- grep: search for text within files.
|
||||||
|
|
||||||
|
## When to Use Filesystem Tools
|
||||||
|
|
||||||
|
BEFORE reaching for any tool, ask yourself: "Can I write a good completion purely from the screenshot?" If yes, just write it — do NOT explore the KB.
|
||||||
|
|
||||||
|
Only use tools when:
|
||||||
|
- The user is clearly writing about a specific topic that likely has detailed information in their KB.
|
||||||
|
- You need a specific fact, name, number, or reference that the screenshot doesn't provide.
|
||||||
|
|
||||||
|
When you do use tools, be surgical:
|
||||||
|
- Check the `ls` output first. If no document title looks relevant, stop — do not read files just to see what's there.
|
||||||
|
- If a title looks relevant, read only the `<chunk_index>` (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:
|
||||||
|
- `<document_metadata>` — title, type, URL, etc.
|
||||||
|
- `<chunk_index>` — a table of every chunk with its **line range** and a
|
||||||
|
`matched="true"` flag for chunks that matched the search query.
|
||||||
|
- `<document_content>` — the actual chunks in original document order.
|
||||||
|
|
||||||
|
**Workflow**: read the first ~20 lines to see the `<chunk_index>`, identify
|
||||||
|
chunks marked `matched="true"`, then use `read_file(path, offset=<start_line>,
|
||||||
|
limit=<lines>)` to jump directly to those sections."""
|
||||||
|
|
||||||
|
APP_CONTEXT_BLOCK = """
|
||||||
|
|
||||||
|
The user is currently working in "{app_name}" (window: "{window_title}"). Use this to understand the type of application and adapt your tone and format accordingly."""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_autocomplete_system_prompt(app_name: str, window_title: str) -> str:
|
||||||
|
prompt = AUTOCOMPLETE_SYSTEM_PROMPT
|
||||||
|
if app_name:
|
||||||
|
prompt += APP_CONTEXT_BLOCK.format(app_name=app_name, window_title=window_title)
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pre-compute KB filesystem (runs in parallel with agent compilation)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _KBResult:
|
||||||
|
"""Container for pre-computed KB filesystem results."""
|
||||||
|
|
||||||
|
__slots__ = ("files", "ls_ai_msg", "ls_tool_msg")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
files: dict[str, Any] | None = None,
|
||||||
|
ls_ai_msg: AIMessage | None = None,
|
||||||
|
ls_tool_msg: ToolMessage | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.files = files
|
||||||
|
self.ls_ai_msg = ls_ai_msg
|
||||||
|
self.ls_tool_msg = ls_tool_msg
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_documents(self) -> bool:
|
||||||
|
return bool(self.files)
|
||||||
|
|
||||||
|
|
||||||
|
async def precompute_kb_filesystem(
|
||||||
|
search_space_id: int,
|
||||||
|
query: str,
|
||||||
|
top_k: int = KB_TOP_K,
|
||||||
|
) -> _KBResult:
|
||||||
|
"""Search the KB and build the scoped filesystem outside the agent.
|
||||||
|
|
||||||
|
This is designed to be called via ``asyncio.gather`` alongside agent
|
||||||
|
graph compilation so the two run concurrently.
|
||||||
|
"""
|
||||||
|
if not query:
|
||||||
|
return _KBResult()
|
||||||
|
|
||||||
|
try:
|
||||||
|
search_results = await search_knowledge_base(
|
||||||
|
query=query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
top_k=top_k,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not search_results:
|
||||||
|
return _KBResult()
|
||||||
|
|
||||||
|
new_files, _ = await build_scoped_filesystem(
|
||||||
|
documents=search_results,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not new_files:
|
||||||
|
return _KBResult()
|
||||||
|
|
||||||
|
doc_paths = [
|
||||||
|
p
|
||||||
|
for p, v in new_files.items()
|
||||||
|
if p.startswith("/documents/") and v is not None
|
||||||
|
]
|
||||||
|
tool_call_id = f"auto_ls_{uuid.uuid4().hex[:12]}"
|
||||||
|
ai_msg = AIMessage(
|
||||||
|
content="",
|
||||||
|
tool_calls=[
|
||||||
|
{"name": "ls", "args": {"path": "/documents"}, "id": tool_call_id}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
tool_msg = ToolMessage(
|
||||||
|
content=str(doc_paths) if doc_paths else "No documents found.",
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
)
|
||||||
|
return _KBResult(files=new_files, ls_ai_msg=ai_msg, ls_tool_msg=tool_msg)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"KB pre-computation failed, proceeding without KB", exc_info=True
|
||||||
|
)
|
||||||
|
return _KBResult()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Filesystem middleware — no save_document, no persistence
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class AutocompleteFilesystemMiddleware(SurfSenseFilesystemMiddleware):
|
||||||
|
"""Filesystem middleware for autocomplete — read-only exploration only.
|
||||||
|
|
||||||
|
Strips ``save_document`` (permanent KB persistence) and passes
|
||||||
|
``search_space_id=None`` so ``write_file`` / ``edit_file`` stay ephemeral.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(search_space_id=None, created_by_id=None)
|
||||||
|
self.tools = [t for t in self.tools if t.name != "save_document"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent factory
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _compile_agent(
|
||||||
|
llm: BaseChatModel,
|
||||||
|
app_name: str,
|
||||||
|
window_title: str,
|
||||||
|
) -> Any:
|
||||||
|
"""Compile the agent graph (CPU-bound, runs in a thread)."""
|
||||||
|
system_prompt = _build_autocomplete_system_prompt(app_name, window_title)
|
||||||
|
final_system_prompt = system_prompt + "\n\n" + BASE_AGENT_PROMPT
|
||||||
|
|
||||||
|
middleware = [
|
||||||
|
AutocompleteFilesystemMiddleware(),
|
||||||
|
PatchToolCallsMiddleware(),
|
||||||
|
AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"),
|
||||||
|
]
|
||||||
|
|
||||||
|
agent = await asyncio.to_thread(
|
||||||
|
create_agent,
|
||||||
|
llm,
|
||||||
|
system_prompt=final_system_prompt,
|
||||||
|
tools=[],
|
||||||
|
middleware=middleware,
|
||||||
|
)
|
||||||
|
return agent.with_config({"recursion_limit": 200})
|
||||||
|
|
||||||
|
|
||||||
|
async def create_autocomplete_agent(
|
||||||
|
llm: BaseChatModel,
|
||||||
|
*,
|
||||||
|
search_space_id: int,
|
||||||
|
kb_query: str,
|
||||||
|
app_name: str = "",
|
||||||
|
window_title: str = "",
|
||||||
|
) -> tuple[Any, _KBResult]:
|
||||||
|
"""Create the autocomplete agent and pre-compute KB in parallel.
|
||||||
|
|
||||||
|
Returns ``(agent, kb_result)`` so the caller can inject the pre-computed
|
||||||
|
filesystem into the agent's initial state without any middleware delay.
|
||||||
|
"""
|
||||||
|
agent, kb = await asyncio.gather(
|
||||||
|
_compile_agent(llm, app_name, window_title),
|
||||||
|
precompute_kb_filesystem(search_space_id, kb_query),
|
||||||
|
)
|
||||||
|
return agent, kb
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Streaming helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def stream_autocomplete_agent(
|
||||||
|
agent: Any,
|
||||||
|
input_data: dict[str, Any],
|
||||||
|
streaming_service: VercelStreamingService,
|
||||||
|
*,
|
||||||
|
emit_message_start: bool = True,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""Stream agent events as Vercel SSE, with thinking steps for tool calls.
|
||||||
|
|
||||||
|
When ``emit_message_start`` is False the caller has already sent the
|
||||||
|
``message_start`` event (e.g. to show preparation steps before the agent
|
||||||
|
runs).
|
||||||
|
"""
|
||||||
|
thread_id = uuid.uuid4().hex
|
||||||
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
|
|
||||||
|
current_text_id: str | None = None
|
||||||
|
active_tool_depth = 0
|
||||||
|
thinking_step_counter = 0
|
||||||
|
tool_step_ids: dict[str, str] = {}
|
||||||
|
step_titles: dict[str, str] = {}
|
||||||
|
completed_step_ids: set[str] = set()
|
||||||
|
last_active_step_id: str | None = None
|
||||||
|
|
||||||
|
def next_thinking_step_id() -> str:
|
||||||
|
nonlocal thinking_step_counter
|
||||||
|
thinking_step_counter += 1
|
||||||
|
return f"autocomplete-step-{thinking_step_counter}"
|
||||||
|
|
||||||
|
def complete_current_step() -> str | None:
|
||||||
|
nonlocal last_active_step_id
|
||||||
|
if last_active_step_id and last_active_step_id not in completed_step_ids:
|
||||||
|
completed_step_ids.add(last_active_step_id)
|
||||||
|
title = step_titles.get(last_active_step_id, "Done")
|
||||||
|
event = streaming_service.format_thinking_step(
|
||||||
|
step_id=last_active_step_id,
|
||||||
|
title=title,
|
||||||
|
status="complete",
|
||||||
|
)
|
||||||
|
last_active_step_id = None
|
||||||
|
return event
|
||||||
|
return None
|
||||||
|
|
||||||
|
if emit_message_start:
|
||||||
|
yield streaming_service.format_message_start()
|
||||||
|
|
||||||
|
# Emit an initial "Generating completion" step so the UI immediately
|
||||||
|
# shows activity once the agent starts its first LLM call.
|
||||||
|
gen_step_id = next_thinking_step_id()
|
||||||
|
last_active_step_id = gen_step_id
|
||||||
|
step_titles[gen_step_id] = "Generating completion"
|
||||||
|
yield streaming_service.format_thinking_step(
|
||||||
|
step_id=gen_step_id,
|
||||||
|
title="Generating completion",
|
||||||
|
status="in_progress",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async for event in agent.astream_events(
|
||||||
|
input_data, config=config, version="v2"
|
||||||
|
):
|
||||||
|
event_type = event.get("event", "")
|
||||||
|
|
||||||
|
if event_type == "on_chat_model_stream":
|
||||||
|
if active_tool_depth > 0:
|
||||||
|
continue
|
||||||
|
if "surfsense:internal" in event.get("tags", []):
|
||||||
|
continue
|
||||||
|
chunk = event.get("data", {}).get("chunk")
|
||||||
|
if chunk and hasattr(chunk, "content"):
|
||||||
|
content = chunk.content
|
||||||
|
if content and isinstance(content, str):
|
||||||
|
if current_text_id is None:
|
||||||
|
step_event = complete_current_step()
|
||||||
|
if step_event:
|
||||||
|
yield step_event
|
||||||
|
current_text_id = streaming_service.generate_text_id()
|
||||||
|
yield streaming_service.format_text_start(current_text_id)
|
||||||
|
yield streaming_service.format_text_delta(
|
||||||
|
current_text_id, content
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event_type == "on_tool_start":
|
||||||
|
active_tool_depth += 1
|
||||||
|
tool_name = event.get("name", "unknown_tool")
|
||||||
|
run_id = event.get("run_id", "")
|
||||||
|
tool_input = event.get("data", {}).get("input", {})
|
||||||
|
|
||||||
|
if current_text_id is not None:
|
||||||
|
yield streaming_service.format_text_end(current_text_id)
|
||||||
|
current_text_id = None
|
||||||
|
|
||||||
|
step_event = complete_current_step()
|
||||||
|
if step_event:
|
||||||
|
yield step_event
|
||||||
|
|
||||||
|
tool_step_id = next_thinking_step_id()
|
||||||
|
tool_step_ids[run_id] = tool_step_id
|
||||||
|
last_active_step_id = tool_step_id
|
||||||
|
|
||||||
|
title, items = _describe_tool_call(tool_name, tool_input)
|
||||||
|
step_titles[tool_step_id] = title
|
||||||
|
yield streaming_service.format_thinking_step(
|
||||||
|
step_id=tool_step_id,
|
||||||
|
title=title,
|
||||||
|
status="in_progress",
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif event_type == "on_tool_end":
|
||||||
|
active_tool_depth = max(0, active_tool_depth - 1)
|
||||||
|
run_id = event.get("run_id", "")
|
||||||
|
step_id = tool_step_ids.pop(run_id, None)
|
||||||
|
if step_id and step_id not in completed_step_ids:
|
||||||
|
completed_step_ids.add(step_id)
|
||||||
|
title = step_titles.get(step_id, "Done")
|
||||||
|
yield streaming_service.format_thinking_step(
|
||||||
|
step_id=step_id,
|
||||||
|
title=title,
|
||||||
|
status="complete",
|
||||||
|
)
|
||||||
|
if last_active_step_id == step_id:
|
||||||
|
last_active_step_id = None
|
||||||
|
|
||||||
|
if current_text_id is not None:
|
||||||
|
yield streaming_service.format_text_end(current_text_id)
|
||||||
|
step_event = complete_current_step()
|
||||||
|
if step_event:
|
||||||
|
yield step_event
|
||||||
|
|
||||||
|
yield streaming_service.format_finish()
|
||||||
|
yield streaming_service.format_done()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Autocomplete agent streaming error: {e}", exc_info=True)
|
||||||
|
if current_text_id is not None:
|
||||||
|
yield streaming_service.format_text_end(current_text_id)
|
||||||
|
yield streaming_service.format_error("Autocomplete failed. Please try again.")
|
||||||
|
yield streaming_service.format_done()
|
||||||
|
|
||||||
|
|
||||||
|
def _describe_tool_call(tool_name: str, tool_input: Any) -> tuple[str, list[str]]:
|
||||||
|
"""Return a human-readable (title, items) for a tool call thinking step."""
|
||||||
|
inp = tool_input if isinstance(tool_input, dict) else {}
|
||||||
|
if tool_name == "ls":
|
||||||
|
path = inp.get("path", "/")
|
||||||
|
return "Listing files", [path]
|
||||||
|
if tool_name == "read_file":
|
||||||
|
fp = inp.get("file_path", "")
|
||||||
|
display = fp if len(fp) <= 80 else "…" + fp[-77:]
|
||||||
|
return "Reading file", [display]
|
||||||
|
if tool_name == "write_file":
|
||||||
|
fp = inp.get("file_path", "")
|
||||||
|
display = fp if len(fp) <= 80 else "…" + fp[-77:]
|
||||||
|
return "Writing file", [display]
|
||||||
|
if tool_name == "edit_file":
|
||||||
|
fp = inp.get("file_path", "")
|
||||||
|
display = fp if len(fp) <= 80 else "…" + fp[-77:]
|
||||||
|
return "Editing file", [display]
|
||||||
|
if tool_name == "glob":
|
||||||
|
pat = inp.get("pattern", "")
|
||||||
|
base = inp.get("path", "/")
|
||||||
|
return "Searching files", [f"{pat} in {base}"]
|
||||||
|
if tool_name == "grep":
|
||||||
|
pat = inp.get("pattern", "")
|
||||||
|
path = inp.get("path", "")
|
||||||
|
display_pat = pat[:60] + ("…" if len(pat) > 60 else "")
|
||||||
|
return "Searching content", [
|
||||||
|
f'"{display_pat}"' + (f" in {path}" if path else "")
|
||||||
|
]
|
||||||
|
return f"Using {tool_name}", []
|
||||||
|
|
@ -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
|
import logging
|
||||||
from collections.abc import AsyncGenerator
|
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 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.llm_service import get_vision_llm
|
||||||
from app.services.new_streaming_service import VercelStreamingService
|
from app.services.new_streaming_service import VercelStreamingService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
KB_TOP_K = 5
|
PREP_STEP_ID = "autocomplete-prep"
|
||||||
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.
|
|
||||||
|
|
||||||
<knowledge_base>
|
|
||||||
{kb_context}
|
|
||||||
</knowledge_base>"""
|
|
||||||
|
|
||||||
|
|
||||||
def _build_system_prompt(app_name: str, window_title: str, kb_context: str) -> str:
|
def _derive_kb_query(app_name: str, window_title: str) -> str:
|
||||||
"""Assemble the system prompt from optional context blocks."""
|
parts = [p for p in (window_title, app_name) if p]
|
||||||
prompt = VISION_SYSTEM_PROMPT
|
return " ".join(parts)
|
||||||
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 _is_vision_unsupported_error(e: Exception) -> bool:
|
def _is_vision_unsupported_error(e: Exception) -> bool:
|
||||||
"""Check if an exception indicates the model doesn't support vision/images."""
|
|
||||||
msg = str(e).lower()
|
msg = str(e).lower()
|
||||||
return "content must be a string" in msg or "does not support image" in msg
|
return "content must be a string" in msg or "does not support image" in msg
|
||||||
|
|
||||||
|
|
||||||
async def _extract_query_from_screenshot(
|
# ---------------------------------------------------------------------------
|
||||||
llm,
|
# Main entry point
|
||||||
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 ""
|
|
||||||
|
|
||||||
|
|
||||||
async def stream_vision_autocomplete(
|
async def stream_vision_autocomplete(
|
||||||
|
|
@ -154,13 +45,7 @@ async def stream_vision_autocomplete(
|
||||||
app_name: str = "",
|
app_name: str = "",
|
||||||
window_title: str = "",
|
window_title: str = "",
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""Analyze a screenshot with the vision LLM and stream a text completion.
|
"""Analyze a screenshot with a vision-LLM agent 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
|
|
||||||
"""
|
|
||||||
streaming = VercelStreamingService()
|
streaming = VercelStreamingService()
|
||||||
vision_error_msg = (
|
vision_error_msg = (
|
||||||
"The selected model does not support vision. "
|
"The selected model does not support vision. "
|
||||||
|
|
@ -174,71 +59,100 @@ async def stream_vision_autocomplete(
|
||||||
yield streaming.format_done()
|
yield streaming.format_done()
|
||||||
return
|
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:
|
try:
|
||||||
query = await _extract_query_from_screenshot(
|
agent, kb = await create_autocomplete_agent(
|
||||||
llm,
|
llm,
|
||||||
screenshot_data_url,
|
search_space_id=search_space_id,
|
||||||
|
kb_query=kb_query,
|
||||||
app_name=app_name,
|
app_name=app_name,
|
||||||
window_title=window_title,
|
window_title=window_title,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
if _is_vision_unsupported_error(e):
|
||||||
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_message_start()
|
yield streaming.format_done()
|
||||||
yield streaming.format_error(vision_error_msg)
|
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()
|
yield streaming.format_done()
|
||||||
return
|
return
|
||||||
|
|
||||||
if query:
|
has_kb = kb.has_documents
|
||||||
kb_context = await _search_knowledge_base(session, search_space_id, query)
|
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 = [
|
# Build agent input with pre-computed KB as initial state
|
||||||
SystemMessage(content=system_prompt),
|
if has_kb:
|
||||||
HumanMessage(
|
instruction = (
|
||||||
content=[
|
"Analyze this screenshot, then explore the knowledge base documents "
|
||||||
{
|
"listed above — read the chunk index of any document whose title "
|
||||||
"type": "text",
|
"looks relevant and check matched chunks for useful facts. "
|
||||||
"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.",
|
"Finally, generate a concise autocomplete for the active text area, "
|
||||||
},
|
"enhanced with any relevant KB information you found."
|
||||||
{
|
)
|
||||||
"type": "image_url",
|
else:
|
||||||
"image_url": {"url": screenshot_data_url},
|
instruction = (
|
||||||
},
|
"Analyze this screenshot and generate a concise autocomplete "
|
||||||
]
|
"for the active text area based on what you see."
|
||||||
),
|
)
|
||||||
]
|
|
||||||
|
|
||||||
text_started = False
|
user_message = HumanMessage(
|
||||||
text_id = ""
|
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:
|
try:
|
||||||
yield streaming.format_message_start()
|
async for sse in stream_autocomplete_agent(
|
||||||
text_id = streaming.generate_text_id()
|
agent,
|
||||||
yield streaming.format_text_start(text_id)
|
input_data,
|
||||||
text_started = True
|
streaming,
|
||||||
|
emit_message_start=False,
|
||||||
async for chunk in llm.astream(messages):
|
):
|
||||||
token = chunk.content if hasattr(chunk, "content") else str(chunk)
|
yield sse
|
||||||
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()
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if text_started:
|
|
||||||
yield streaming.format_text_end(text_id)
|
|
||||||
|
|
||||||
if _is_vision_unsupported_error(e):
|
if _is_vision_unsupported_error(e):
|
||||||
logger.warning(
|
logger.warning("Vision autocomplete: model does not support vision: %s", e)
|
||||||
f"Vision autocomplete: selected model does not support vision: {e}"
|
|
||||||
)
|
|
||||||
yield streaming.format_error(vision_error_msg)
|
yield streaming.format_error(vision_error_msg)
|
||||||
|
yield streaming.format_done()
|
||||||
else:
|
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_error("Autocomplete failed. Please try again.")
|
||||||
yield streaming.format_done()
|
yield streaming.format_done()
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,6 @@ dependencies = [
|
||||||
"redis>=5.2.1",
|
"redis>=5.2.1",
|
||||||
"firecrawl-py>=4.9.0",
|
"firecrawl-py>=4.9.0",
|
||||||
"boto3>=1.35.0",
|
"boto3>=1.35.0",
|
||||||
"litellm>=1.80.10",
|
|
||||||
"langchain-litellm>=0.3.5",
|
|
||||||
"fake-useragent>=2.2.0",
|
"fake-useragent>=2.2.0",
|
||||||
"trafilatura>=2.0.0",
|
"trafilatura>=2.0.0",
|
||||||
"fastapi-users[oauth,sqlalchemy]>=15.0.3",
|
"fastapi-users[oauth,sqlalchemy]>=15.0.3",
|
||||||
|
|
@ -75,6 +73,8 @@ dependencies = [
|
||||||
"langchain-community>=0.4.1",
|
"langchain-community>=0.4.1",
|
||||||
"deepagents>=0.4.12",
|
"deepagents>=0.4.12",
|
||||||
"stripe>=15.0.0",
|
"stripe>=15.0.0",
|
||||||
|
"litellm>=1.83.0",
|
||||||
|
"langchain-litellm>=0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
180
surfsense_backend/uv.lock
generated
180
surfsense_backend/uv.lock
generated
|
|
@ -62,7 +62,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohttp"
|
name = "aiohttp"
|
||||||
version = "3.13.3"
|
version = "3.13.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohappyeyeballs" },
|
{ name = "aiohappyeyeballs" },
|
||||||
|
|
@ -73,76 +73,76 @@ dependencies = [
|
||||||
{ name = "propcache" },
|
{ name = "propcache" },
|
||||||
{ name = "yarl" },
|
{ name = "yarl" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556 }
|
sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732 },
|
{ url = "https://files.pythonhosted.org/packages/be/6f/353954c29e7dcce7cf00280a02c75f30e133c00793c7a2ed3776d7b2f426/aiohttp-3.13.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:023ecba036ddd840b0b19bf195bfae970083fd7024ce1ac22e9bba90464620e9", size = 748876 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293 },
|
{ url = "https://files.pythonhosted.org/packages/f5/1b/428a7c64687b3b2e9cd293186695affc0e1e54a445d0361743b231f11066/aiohttp-3.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15c933ad7920b7d9a20de151efcd05a6e38302cbf0e10c9b2acb9a42210a2416", size = 499557 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533 },
|
{ url = "https://files.pythonhosted.org/packages/29/47/7be41556bfbb6917069d6a6634bb7dd5e163ba445b783a90d40f5ac7e3a7/aiohttp-3.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab2899f9fa2f9f741896ebb6fa07c4c883bfa5c7f2ddd8cf2aafa86fa981b2d2", size = 500258 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839 },
|
{ url = "https://files.pythonhosted.org/packages/67/84/c9ecc5828cb0b3695856c07c0a6817a99d51e2473400f705275a2b3d9239/aiohttp-3.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60eaa2d440cd4707696b52e40ed3e2b0f73f65be07fd0ef23b6b539c9c0b0b4", size = 1749199 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932 },
|
{ url = "https://files.pythonhosted.org/packages/f0/d3/3c6d610e66b495657622edb6ae7c7fd31b2e9086b4ec50b47897ad6042a9/aiohttp-3.13.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:55b3bdd3292283295774ab585160c4004f4f2f203946997f49aac032c84649e9", size = 1721013 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906 },
|
{ url = "https://files.pythonhosted.org/packages/49/a0/24409c12217456df0bae7babe3b014e460b0b38a8e60753d6cb339f6556d/aiohttp-3.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2b2355dc094e5f7d45a7bb262fe7207aa0460b37a0d87027dcf21b5d890e7d5", size = 1781501 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020 },
|
{ url = "https://files.pythonhosted.org/packages/98/9d/b65ec649adc5bccc008b0957a9a9c691070aeac4e41cea18559fef49958b/aiohttp-3.13.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b38765950832f7d728297689ad78f5f2cf79ff82487131c4d26fe6ceecdc5f8e", size = 1878981 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181 },
|
{ url = "https://files.pythonhosted.org/packages/57/d8/8d44036d7eb7b6a8ec4c5494ea0c8c8b94fbc0ed3991c1a7adf230df03bf/aiohttp-3.13.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b18f31b80d5a33661e08c89e202edabf1986e9b49c42b4504371daeaa11b47c1", size = 1767934 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794 },
|
{ url = "https://files.pythonhosted.org/packages/31/04/d3f8211f273356f158e3464e9e45484d3fb8c4ce5eb2f6fe9405c3273983/aiohttp-3.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:33add2463dde55c4f2d9635c6ab33ce154e5ecf322bd26d09af95c5f81cfa286", size = 1566671 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900 },
|
{ url = "https://files.pythonhosted.org/packages/41/db/073e4ebe00b78e2dfcacff734291651729a62953b48933d765dc513bf798/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:327cc432fdf1356fb4fbc6fe833ad4e9f6aacb71a8acaa5f1855e4b25910e4a9", size = 1705219 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239 },
|
{ url = "https://files.pythonhosted.org/packages/48/45/7dfba71a2f9fd97b15c95c06819de7eb38113d2cdb6319669195a7d64270/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7c35b0bf0b48a70b4cb4fc5d7bed9b932532728e124874355de1a0af8ec4bc88", size = 1743049 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527 },
|
{ url = "https://files.pythonhosted.org/packages/18/71/901db0061e0f717d226386a7f471bb59b19566f2cae5f0d93874b017271f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:df23d57718f24badef8656c49743e11a89fd6f5358fa8a7b96e728fda2abf7d3", size = 1749557 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489 },
|
{ url = "https://files.pythonhosted.org/packages/08/d5/41eebd16066e59cd43728fe74bce953d7402f2b4ddfdfef2c0e9f17ca274/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:02e048037a6501a5ec1f6fc9736135aec6eb8a004ce48838cb951c515f32c80b", size = 1558931 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852 },
|
{ url = "https://files.pythonhosted.org/packages/30/e6/4a799798bf05740e66c3a1161079bda7a3dd8e22ca392481d7a7f9af82a6/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31cebae8b26f8a615d2b546fee45d5ffb76852ae6450e2a03f42c9102260d6fe", size = 1774125 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379 },
|
{ url = "https://files.pythonhosted.org/packages/84/63/7749337c90f92bc2cb18f9560d67aa6258c7060d1397d21529b8004fcf6f/aiohttp-3.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:888e78eb5ca55a615d285c3c09a7a91b42e9dd6fc699b166ebd5dee87c9ccf14", size = 1732427 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253 },
|
{ url = "https://files.pythonhosted.org/packages/98/de/cf2f44ff98d307e72fb97d5f5bbae3bfcb442f0ea9790c0bf5c5c2331404/aiohttp-3.13.5-cp312-cp312-win32.whl", hash = "sha256:8bd3ec6376e68a41f9f95f5ed170e2fcf22d4eb27a1f8cb361d0508f6e0557f3", size = 433534 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407 },
|
{ url = "https://files.pythonhosted.org/packages/aa/ca/eadf6f9c8fa5e31d40993e3db153fb5ed0b11008ad5d9de98a95045bed84/aiohttp-3.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:110e448e02c729bcebb18c60b9214a87ba33bac4a9fa5e9a5f139938b56c6cb1", size = 460446 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190 },
|
{ url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783 },
|
{ url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704 },
|
{ url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652 },
|
{ url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014 },
|
{ url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777 },
|
{ url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276 },
|
{ url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131 },
|
{ url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863 },
|
{ url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793 },
|
{ url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676 },
|
{ url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217 },
|
{ url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303 },
|
{ url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673 },
|
{ url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120 },
|
{ url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383 },
|
{ url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899 },
|
{ url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238 },
|
{ url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292 },
|
{ url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021 },
|
{ url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263 },
|
{ url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107 },
|
{ url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196 },
|
{ url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591 },
|
{ url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277 },
|
{ url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575 },
|
{ url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455 },
|
{ url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417 },
|
{ url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968 },
|
{ url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690 },
|
{ url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390 },
|
{ url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188 },
|
{ url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126 },
|
{ url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128 },
|
{ url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512 },
|
{ url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444 },
|
{ url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798 },
|
{ url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835 },
|
{ url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486 },
|
{ url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951 },
|
{ url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001 },
|
{ url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246 },
|
{ url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131 },
|
{ url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196 },
|
{ url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841 },
|
{ url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193 },
|
{ url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979 },
|
{ url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193 },
|
{ url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801 },
|
{ url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523 },
|
{ url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694 },
|
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1023,14 +1023,14 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.1"
|
version = "8.1.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 }
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 },
|
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2984,14 +2984,14 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "importlib-metadata"
|
name = "importlib-metadata"
|
||||||
version = "8.7.1"
|
version = "8.5.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "zipp" },
|
{ name = "zipp" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107 }
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865 },
|
{ url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3222,7 +3222,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jsonschema"
|
name = "jsonschema"
|
||||||
version = "4.26.0"
|
version = "4.23.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "attrs" },
|
{ name = "attrs" },
|
||||||
|
|
@ -3230,9 +3230,9 @@ dependencies = [
|
||||||
{ name = "referencing" },
|
{ name = "referencing" },
|
||||||
{ name = "rpds-py" },
|
{ name = "rpds-py" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 }
|
sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 },
|
{ url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3533,7 +3533,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langchain-litellm"
|
name = "langchain-litellm"
|
||||||
version = "0.6.2"
|
version = "0.6.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
|
|
@ -3541,9 +3541,9 @@ dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
{ name = "litellm" },
|
{ name = "litellm" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/6f/ba0490ec0fbc9d97cd9433749455fb4b5fbec3852bcbe113a0278ec1d32d/langchain_litellm-0.6.2.tar.gz", hash = "sha256:93372df7c3f1802358746e2c0a94012d8c27d9f9b57b769b23f6af2264bbaabb", size = 332878 }
|
sdist = { url = "https://files.pythonhosted.org/packages/68/37/ccc1f284a42900ca5b267a50da8e50145e9f264b32ee955ce91aa360d188/langchain_litellm-0.6.4.tar.gz", hash = "sha256:663281db392b3de1f07f891d0f80f9d4b26c0f0d2abbf854ef9b186d99c309ee", size = 339457 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/14/ad857a3f56fa4ea0879ac9d6ee5248c883663d0bad94bf8741e1ab6ab200/langchain_litellm-0.6.2-py3-none-any.whl", hash = "sha256:98af79dbcdea4b492e9601351bc5fd15fdd368e021183b8540f0d0b6b6b1589c", size = 24865 },
|
{ url = "https://files.pythonhosted.org/packages/43/e8/25c50bbad7a05106c7af65557e165d6cb6159c90854dae61de59debe735d/langchain_litellm-0.6.4-py3-none-any.whl", hash = "sha256:60f4e37be1a47dc88f94fac7085675ef8fa04bba92f48735792d82f492120744", size = 26360 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3709,7 +3709,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litellm"
|
name = "litellm"
|
||||||
version = "1.82.6"
|
version = "1.83.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|
@ -3725,9 +3725,9 @@ dependencies = [
|
||||||
{ name = "tiktoken" },
|
{ name = "tiktoken" },
|
||||||
{ name = "tokenizers" },
|
{ name = "tokenizers" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/29/75/1c537aa458426a9127a92bc2273787b2f987f4e5044e21f01f2eed5244fd/litellm-1.82.6.tar.gz", hash = "sha256:2aa1c2da21fe940c33613aa447119674a3ad4d2ad5eb064e4d5ce5ee42420136", size = 17414147 }
|
sdist = { url = "https://files.pythonhosted.org/packages/03/c4/30469c06ae7437a4406bc11e3c433cfd380a6771068cca15ea918dcd158f/litellm-1.83.4.tar.gz", hash = "sha256:6458d2030a41229460b321adee00517a91dbd8e63213cc953d355cb41d16f2d4", size = 17733899 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/6c/5327667e6dbe9e98cbfbd4261c8e91386a52e38f41419575854248bbab6a/litellm-1.82.6-py3-none-any.whl", hash = "sha256:164a3ef3e19f309e3cabc199bef3d2045212712fefdfa25fc7f75884a5b5b205", size = 15591595 },
|
{ url = "https://files.pythonhosted.org/packages/b8/bd/df19d3f8f6654535ee343a341fd921f81c411abf601a53e3eaef58129b02/litellm-1.83.4-py3-none-any.whl", hash = "sha256:17d7b4d48d47aca988ea4f762ddda5e7bd72cda3270192b22813d0330869d7b4", size = 16015555 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -6766,11 +6766,11 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 }
|
sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 },
|
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -8049,12 +8049,12 @@ requires-dist = [
|
||||||
{ name = "langchain", specifier = ">=1.2.13" },
|
{ name = "langchain", specifier = ">=1.2.13" },
|
||||||
{ name = "langchain-community", specifier = ">=0.4.1" },
|
{ name = "langchain-community", specifier = ">=0.4.1" },
|
||||||
{ name = "langchain-daytona", specifier = ">=0.0.2" },
|
{ 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 = "langchain-unstructured", specifier = ">=1.0.1" },
|
||||||
{ name = "langgraph", specifier = ">=1.1.3" },
|
{ name = "langgraph", specifier = ">=1.1.3" },
|
||||||
{ name = "langgraph-checkpoint-postgres", specifier = ">=3.0.2" },
|
{ name = "langgraph-checkpoint-postgres", specifier = ">=3.0.2" },
|
||||||
{ name = "linkup-sdk", specifier = ">=0.2.4" },
|
{ 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 = "llama-cloud-services", specifier = ">=0.6.25" },
|
||||||
{ name = "markdown", specifier = ">=3.7" },
|
{ name = "markdown", specifier = ">=3.7" },
|
||||||
{ name = "markdownify", specifier = ">=0.14.1" },
|
{ name = "markdownify", specifier = ">=0.14.1" },
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ win:
|
||||||
icon: assets/icon.ico
|
icon: assets/icon.ico
|
||||||
target:
|
target:
|
||||||
- target: nsis
|
- target: nsis
|
||||||
arch: [x64, arm64]
|
arch: [x64]
|
||||||
nsis:
|
nsis:
|
||||||
oneClick: false
|
oneClick: false
|
||||||
perMachine: false
|
perMachine: false
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"description": "SurfSense Desktop App",
|
"description": "SurfSense Desktop App",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"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",
|
"build": "node scripts/build-electron.mjs",
|
||||||
"pack:dir": "pnpm build && electron-builder --dir --config electron-builder.yml",
|
"pack:dir": "pnpm build && electron-builder --dir --config electron-builder.yml",
|
||||||
"dist": "pnpm build && electron-builder --config electron-builder.yml",
|
"dist": "pnpm build && electron-builder --config electron-builder.yml",
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,10 @@ export const IPC_CHANNELS = {
|
||||||
FOLDER_SYNC_ACK_EVENTS: 'folder-sync:ack-events',
|
FOLDER_SYNC_ACK_EVENTS: 'folder-sync:ack-events',
|
||||||
BROWSE_FILES: 'browse:files',
|
BROWSE_FILES: 'browse:files',
|
||||||
READ_LOCAL_FILES: 'browse:read-local-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',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ import {
|
||||||
browseFiles,
|
browseFiles,
|
||||||
readLocalFiles,
|
readLocalFiles,
|
||||||
} from '../modules/folder-watcher';
|
} from '../modules/folder-watcher';
|
||||||
|
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
|
||||||
|
import { reregisterQuickAsk } from '../modules/quick-ask';
|
||||||
|
import { reregisterAutocomplete } from '../modules/autocomplete';
|
||||||
|
|
||||||
|
let authTokens: { bearer: string; refresh: string } | null = null;
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
ipcMain.on(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url: string) => {
|
ipcMain.on(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url: string) => {
|
||||||
|
|
@ -89,4 +94,21 @@ export function registerIpcHandlers(): void {
|
||||||
ipcMain.handle(IPC_CHANNELS.READ_LOCAL_FILES, (_event, paths: string[]) =>
|
ipcMain.handle(IPC_CHANNELS.READ_LOCAL_FILES, (_event, paths: string[]) =>
|
||||||
readLocalFiles(paths)
|
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.SET_SHORTCUTS, async (_event, config: Partial<ShortcutConfig>) => {
|
||||||
|
const updated = await setShortcuts(config);
|
||||||
|
if (config.quickAsk) await reregisterQuickAsk();
|
||||||
|
if (config.autocomplete) await reregisterAutocomplete();
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ app.whenReady().then(async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
createMainWindow('/dashboard');
|
createMainWindow('/dashboard');
|
||||||
registerQuickAsk();
|
await registerQuickAsk();
|
||||||
registerAutocomplete();
|
await registerAutocomplete();
|
||||||
registerFolderWatcher();
|
registerFolderWatcher();
|
||||||
setupAutoUpdater();
|
setupAutoUpdater();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ import { hasScreenRecordingPermission, requestAccessibility, requestScreenRecord
|
||||||
import { getMainWindow } from '../window';
|
import { getMainWindow } from '../window';
|
||||||
import { captureScreen } from './screenshot';
|
import { captureScreen } from './screenshot';
|
||||||
import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window';
|
import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window';
|
||||||
|
import { getShortcuts } from '../shortcuts';
|
||||||
|
|
||||||
const SHORTCUT = 'CommandOrControl+Shift+Space';
|
let currentShortcut = '';
|
||||||
|
|
||||||
let autocompleteEnabled = true;
|
let autocompleteEnabled = true;
|
||||||
let savedClipboard = '';
|
let savedClipboard = '';
|
||||||
let sourceApp = '';
|
let sourceApp = '';
|
||||||
|
|
@ -91,7 +91,12 @@ async function acceptAndInject(text: string): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ipcRegistered = false;
|
||||||
|
|
||||||
function registerIpcHandlers(): void {
|
function registerIpcHandlers(): void {
|
||||||
|
if (ipcRegistered) return;
|
||||||
|
ipcRegistered = true;
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.ACCEPT_SUGGESTION, async (_event, text: string) => {
|
ipcMain.handle(IPC_CHANNELS.ACCEPT_SUGGESTION, async (_event, text: string) => {
|
||||||
await acceptAndInject(text);
|
await acceptAndInject(text);
|
||||||
});
|
});
|
||||||
|
|
@ -107,26 +112,39 @@ function registerIpcHandlers(): void {
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_AUTOCOMPLETE_ENABLED, () => autocompleteEnabled);
|
ipcMain.handle(IPC_CHANNELS.GET_AUTOCOMPLETE_ENABLED, () => autocompleteEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerAutocomplete(): void {
|
function autocompleteHandler(): void {
|
||||||
registerIpcHandlers();
|
const sw = getSuggestionWindow();
|
||||||
|
if (sw && !sw.isDestroyed()) {
|
||||||
|
destroySuggestion();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerAutocomplete();
|
||||||
|
}
|
||||||
|
|
||||||
const ok = globalShortcut.register(SHORTCUT, () => {
|
async function registerShortcut(): Promise<void> {
|
||||||
const sw = getSuggestionWindow();
|
const shortcuts = await getShortcuts();
|
||||||
if (sw && !sw.isDestroyed()) {
|
currentShortcut = shortcuts.autocomplete;
|
||||||
destroySuggestion();
|
|
||||||
return;
|
const ok = globalShortcut.register(currentShortcut, autocompleteHandler);
|
||||||
}
|
|
||||||
triggerAutocomplete();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
console.error(`[autocomplete] Failed to register shortcut ${SHORTCUT}`);
|
console.error(`[autocomplete] Failed to register shortcut ${currentShortcut}`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[autocomplete] Registered shortcut ${SHORTCUT}`);
|
console.log(`[autocomplete] Registered shortcut ${currentShortcut}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function registerAutocomplete(): Promise<void> {
|
||||||
|
registerIpcHandlers();
|
||||||
|
await registerShortcut();
|
||||||
|
}
|
||||||
|
|
||||||
export function unregisterAutocomplete(): void {
|
export function unregisterAutocomplete(): void {
|
||||||
globalShortcut.unregister(SHORTCUT);
|
if (currentShortcut) globalShortcut.unregister(currentShortcut);
|
||||||
destroySuggestion();
|
destroySuggestion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reregisterAutocomplete(): Promise<void> {
|
||||||
|
unregisterAutocomplete();
|
||||||
|
await registerShortcut();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import { systemPreferences } from 'electron';
|
import { systemPreferences } from 'electron';
|
||||||
|
|
||||||
|
const EXEC_OPTS = { windowsHide: true } as const;
|
||||||
|
|
||||||
export function getFrontmostApp(): string {
|
export function getFrontmostApp(): string {
|
||||||
try {
|
try {
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
return execSync(
|
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();
|
).toString().trim();
|
||||||
}
|
}
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return execSync(
|
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();
|
).toString().trim();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -21,9 +25,23 @@ export function getFrontmostApp(): string {
|
||||||
|
|
||||||
export function simulatePaste(): void {
|
export function simulatePaste(): void {
|
||||||
if (process.platform === 'darwin') {
|
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') {
|
} 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 {
|
try {
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === 'darwin') {
|
||||||
return execSync(
|
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();
|
).toString().trim();
|
||||||
}
|
}
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
return execSync(
|
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();
|
).toString().trim();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron';
|
import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { IPC_CHANNELS } from '../ipc/channels';
|
import { IPC_CHANNELS } from '../ipc/channels';
|
||||||
import { checkAccessibilityPermission, getFrontmostApp, simulatePaste } from './platform';
|
import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform';
|
||||||
import { getServerPort } from './server';
|
import { getServerPort } from './server';
|
||||||
|
import { getShortcuts } from './shortcuts';
|
||||||
|
|
||||||
const SHORTCUT = 'CommandOrControl+Option+S';
|
let currentShortcut = '';
|
||||||
let quickAskWindow: BrowserWindow | null = null;
|
let quickAskWindow: BrowserWindow | null = null;
|
||||||
let pendingText = '';
|
let pendingText = '';
|
||||||
let pendingMode = '';
|
let pendingMode = '';
|
||||||
|
|
@ -77,29 +78,52 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
|
||||||
return quickAskWindow;
|
return quickAskWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerQuickAsk(): void {
|
function openQuickAsk(text: string): void {
|
||||||
const ok = globalShortcut.register(SHORTCUT, () => {
|
pendingText = text;
|
||||||
if (quickAskWindow && !quickAskWindow.isDestroyed()) {
|
const cursor = screen.getCursorScreenPoint();
|
||||||
destroyQuickAsk();
|
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
|
||||||
return;
|
createQuickAskWindow(pos.x, pos.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceApp = getFrontmostApp();
|
async function quickAskHandler(): Promise<void> {
|
||||||
savedClipboard = clipboard.readText();
|
console.log('[quick-ask] Handler triggered');
|
||||||
|
|
||||||
const text = savedClipboard.trim();
|
if (quickAskWindow && !quickAskWindow.isDestroyed()) {
|
||||||
if (!text) return;
|
console.log('[quick-ask] Window already open, closing');
|
||||||
|
destroyQuickAsk();
|
||||||
pendingText = text;
|
return;
|
||||||
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 (!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 Ask with', text.length, 'chars', selected ? '(selected)' : text ? '(clipboard fallback)' : '(empty)');
|
||||||
|
openQuickAsk(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ipcRegistered = false;
|
||||||
|
|
||||||
|
function registerIpcHandlers(): void {
|
||||||
|
if (ipcRegistered) return;
|
||||||
|
ipcRegistered = true;
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => {
|
ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => {
|
||||||
const text = pendingText;
|
const text = pendingText;
|
||||||
pendingText = '';
|
pendingText = '';
|
||||||
|
|
@ -136,6 +160,24 @@ export function registerQuickAsk(): void {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unregisterQuickAsk(): void {
|
async function registerShortcut(): Promise<void> {
|
||||||
globalShortcut.unregister(SHORTCUT);
|
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<void> {
|
||||||
|
registerIpcHandlers();
|
||||||
|
await registerShortcut();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterQuickAsk(): void {
|
||||||
|
if (currentShortcut) globalShortcut.unregister(currentShortcut);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reregisterQuickAsk(): Promise<void> {
|
||||||
|
unregisterQuickAsk();
|
||||||
|
await registerShortcut();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
42
surfsense_desktop/src/modules/shortcuts.ts
Normal file
42
surfsense_desktop/src/modules/shortcuts.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
export interface ShortcutConfig {
|
||||||
|
quickAsk: string;
|
||||||
|
autocomplete: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: ShortcutConfig = {
|
||||||
|
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<ShortcutConfig> {
|
||||||
|
const s = await getStore();
|
||||||
|
const stored = s.get(STORE_KEY) as Partial<ShortcutConfig> | undefined;
|
||||||
|
return { ...DEFAULTS, ...stored };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setShortcuts(config: Partial<ShortcutConfig>): Promise<ShortcutConfig> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
@ -68,4 +68,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
// Browse files via native dialog
|
// Browse files via native dialog
|
||||||
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
|
browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES),
|
||||||
readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths),
|
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<string, string>) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,51 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { Clipboard, Sparkles } from "lucide-react";
|
||||||
|
import { useCallback, 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
|
||||||
export function DesktopContent() {
|
export function DesktopContent() {
|
||||||
const [isElectron, setIsElectron] = useState(false);
|
const api = useElectronAPI();
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [enabled, setEnabled] = useState(true);
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
|
||||||
|
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||||
|
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI) {
|
if (!api) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
setShortcutsLoaded(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsElectron(true);
|
|
||||||
|
|
||||||
window.electronAPI.getAutocompleteEnabled().then((val) => {
|
let mounted = true;
|
||||||
setEnabled(val);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!isElectron) {
|
Promise.all([api.getAutocompleteEnabled(), api.getShortcuts?.() ?? Promise.resolve(null)])
|
||||||
|
.then(([autoEnabled, config]) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setEnabled(autoEnabled);
|
||||||
|
if (config) setShortcuts(config);
|
||||||
|
setLoading(false);
|
||||||
|
setShortcutsLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setLoading(false);
|
||||||
|
setShortcutsLoaded(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
if (!api) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|
@ -44,11 +65,68 @@ export function DesktopContent() {
|
||||||
|
|
||||||
const handleToggle = async (checked: boolean) => {
|
const handleToggle = async (checked: boolean) => {
|
||||||
setEnabled(checked);
|
setEnabled(checked);
|
||||||
await window.electronAPI!.setAutocompleteEnabled(checked);
|
await api.setAutocompleteEnabled(checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateShortcut = (key: "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: "quickAsk" | "autocomplete") => {
|
||||||
|
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
|
{/* Keyboard Shortcuts */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||||
|
<CardTitle className="text-base md:text-lg">Keyboard Shortcuts</CardTitle>
|
||||||
|
<CardDescription className="text-xs md:text-sm">
|
||||||
|
Customize the global keyboard shortcuts for desktop features.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||||
|
{shortcutsLoaded ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<ShortcutRecorder
|
||||||
|
value={shortcuts.quickAsk}
|
||||||
|
onChange={(accel) => updateShortcut("quickAsk", accel)}
|
||||||
|
onReset={() => resetShortcut("quickAsk")}
|
||||||
|
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
|
||||||
|
label="Quick Ask"
|
||||||
|
description="Copy selected text and ask AI about it"
|
||||||
|
icon={Clipboard}
|
||||||
|
/>
|
||||||
|
<ShortcutRecorder
|
||||||
|
value={shortcuts.autocomplete}
|
||||||
|
onChange={(accel) => updateShortcut("autocomplete", accel)}
|
||||||
|
onReset={() => resetShortcut("autocomplete")}
|
||||||
|
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
|
||||||
|
label="Autocomplete"
|
||||||
|
description="Get AI writing suggestions from a screenshot"
|
||||||
|
icon={Sparkles}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
Click a shortcut and press a new key combination to change it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Autocomplete Toggle */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||||
<CardTitle className="text-base md:text-lg">Autocomplete</CardTitle>
|
<CardTitle className="text-base md:text-lg">Autocomplete</CardTitle>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
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";
|
import { queryClient } from "@/lib/query-client/client";
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
|
|
@ -17,15 +17,20 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
useGlobalLoadingEffect(isCheckingAuth);
|
useGlobalLoadingEffect(isCheckingAuth);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if user is authenticated
|
async function checkAuth() {
|
||||||
const token = getBearerToken();
|
let token = getBearerToken();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
// Save current path and redirect to login
|
const synced = await ensureTokensFromElectron();
|
||||||
redirectToLogin();
|
if (synced) token = getBearerToken();
|
||||||
return;
|
}
|
||||||
|
if (!token) {
|
||||||
|
redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] });
|
||||||
|
setIsCheckingAuth(false);
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: [...USER_QUERY_KEY] });
|
checkAuth();
|
||||||
setIsCheckingAuth(false);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Return null while loading - the global provider handles the loading UI
|
// Return null while loading - the global provider handles the loading UI
|
||||||
|
|
|
||||||
234
surfsense_web/app/desktop/login/page.tsx
Normal file
234
surfsense_web/app/desktop/login/page.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { Clipboard, Eye, EyeOff, Keyboard, Sparkles } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
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 { 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<string | null>(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: "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: "quickAsk" | "autocomplete") => {
|
||||||
|
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
||||||
|
},
|
||||||
|
[updateShortcut]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGoogleLogin = () => {
|
||||||
|
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="relative flex min-h-screen items-center justify-center bg-background p-4">
|
||||||
|
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute -top-1/2 left-1/2 size-[800px] -translate-x-1/2 rounded-full opacity-[0.03]"
|
||||||
|
style={{
|
||||||
|
background: "radial-gradient(circle, hsl(var(--primary)) 0%, transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="relative w-full max-w-md shadow-lg">
|
||||||
|
<CardHeader className="items-center text-center pb-4">
|
||||||
|
<Image
|
||||||
|
src="/icon-128.svg"
|
||||||
|
className="select-none dark:invert size-14 rounded-md mb-2"
|
||||||
|
alt="SurfSense"
|
||||||
|
width={56}
|
||||||
|
height={56}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<CardTitle className="text-xl">Welcome to SurfSense Desktop App</CardTitle>
|
||||||
|
<CardDescription>Configure your shortcuts, then sign in to get started.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col gap-6">
|
||||||
|
{/* ---- Shortcuts Section (first) ---- */}
|
||||||
|
{shortcutsLoaded ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-1">
|
||||||
|
<Keyboard className="size-3" />
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</div>
|
||||||
|
<ShortcutRecorder
|
||||||
|
value={shortcuts.quickAsk}
|
||||||
|
onChange={(accel) => updateShortcut("quickAsk", accel)}
|
||||||
|
onReset={() => resetShortcut("quickAsk")}
|
||||||
|
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
|
||||||
|
label="Quick Ask"
|
||||||
|
description="Copy selected text and ask AI about it"
|
||||||
|
icon={Clipboard}
|
||||||
|
/>
|
||||||
|
<ShortcutRecorder
|
||||||
|
value={shortcuts.autocomplete}
|
||||||
|
onChange={(accel) => updateShortcut("autocomplete", accel)}
|
||||||
|
onReset={() => resetShortcut("autocomplete")}
|
||||||
|
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
|
||||||
|
label="Autocomplete"
|
||||||
|
description="Get AI writing suggestions from a screenshot"
|
||||||
|
icon={Sparkles}
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-muted-foreground text-center">
|
||||||
|
Click a shortcut and press a new key combination to change it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Divider ---- */}
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* ---- Auth Section (second) ---- */}
|
||||||
|
{isGoogleAuth ? (
|
||||||
|
<Button variant="outline" className="w-full gap-2 py-5" onClick={handleGoogleLogin}>
|
||||||
|
<IconBrandGoogleFilled className="size-5" />
|
||||||
|
Continue with Google
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleLocalLogin} className="flex flex-col gap-4">
|
||||||
|
{loginError && (
|
||||||
|
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{loginError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={isLoggingIn} className="mt-1">
|
||||||
|
{isLoggingIn ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" className="text-primary-foreground" />
|
||||||
|
Signing in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Sign in"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
|
||||||
type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted" | "limited";
|
type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted" | "limited";
|
||||||
|
|
||||||
|
|
@ -58,19 +59,18 @@ function StatusBadge({ status }: { status: PermissionStatus }) {
|
||||||
|
|
||||||
export default function DesktopPermissionsPage() {
|
export default function DesktopPermissionsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const api = useElectronAPI();
|
||||||
const [permissions, setPermissions] = useState<PermissionsStatus | null>(null);
|
const [permissions, setPermissions] = useState<PermissionsStatus | null>(null);
|
||||||
const [isElectron, setIsElectron] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI) return;
|
if (!api) return;
|
||||||
setIsElectron(true);
|
|
||||||
|
|
||||||
let interval: ReturnType<typeof setInterval> | null = null;
|
let interval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
const isResolved = (s: string) => s === "authorized" || s === "restricted";
|
const isResolved = (s: string) => s === "authorized" || s === "restricted";
|
||||||
|
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
const status = await window.electronAPI!.getPermissionsStatus();
|
const status = await api.getPermissionsStatus();
|
||||||
setPermissions(status);
|
setPermissions(status);
|
||||||
|
|
||||||
if (isResolved(status.accessibility) && isResolved(status.screenRecording)) {
|
if (isResolved(status.accessibility) && isResolved(status.screenRecording)) {
|
||||||
|
|
@ -83,9 +83,9 @@ export default function DesktopPermissionsPage() {
|
||||||
return () => {
|
return () => {
|
||||||
if (interval) clearInterval(interval);
|
if (interval) clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [api]);
|
||||||
|
|
||||||
if (!isElectron) {
|
if (!api) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex items-center justify-center bg-background">
|
<div className="h-screen flex items-center justify-center bg-background">
|
||||||
<p className="text-muted-foreground">This page is only available in the desktop app.</p>
|
<p className="text-muted-foreground">This page is only available in the desktop app.</p>
|
||||||
|
|
@ -106,15 +106,15 @@ export default function DesktopPermissionsPage() {
|
||||||
|
|
||||||
const handleRequest = async (action: string) => {
|
const handleRequest = async (action: string) => {
|
||||||
if (action === "requestScreenRecording") {
|
if (action === "requestScreenRecording") {
|
||||||
await window.electronAPI!.requestScreenRecording();
|
await api.requestScreenRecording();
|
||||||
} else if (action === "requestAccessibility") {
|
} else if (action === "requestAccessibility") {
|
||||||
await window.electronAPI!.requestAccessibility();
|
await api.requestAccessibility();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContinue = () => {
|
const handleContinue = () => {
|
||||||
if (allGranted) {
|
if (allGranted) {
|
||||||
window.electronAPI!.restartApp();
|
api.restartApp();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -206,6 +206,7 @@ export default function DesktopPermissionsPage() {
|
||||||
Grant permissions to continue
|
Grant permissions to continue
|
||||||
</Button>
|
</Button>
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
className="block mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="block mx-auto text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
|
import { ensureTokensFromElectron, getBearerToken } from "@/lib/auth-utils";
|
||||||
|
|
||||||
type SSEEvent =
|
type SSEEvent =
|
||||||
| { type: "text-delta"; id: string; delta: string }
|
| { type: "text-delta"; id: string; delta: string }
|
||||||
|
|
@ -9,7 +10,18 @@ type SSEEvent =
|
||||||
| { type: "text-end"; id: string }
|
| { type: "text-end"; id: string }
|
||||||
| { type: "start"; messageId: string }
|
| { type: "start"; messageId: string }
|
||||||
| { type: "finish" }
|
| { type: "finish" }
|
||||||
| { type: "error"; errorText: string };
|
| { type: "error"; errorText: string }
|
||||||
|
| {
|
||||||
|
type: "data-thinking-step";
|
||||||
|
data: { id: string; title: string; status: string; items: string[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AgentStep {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
function friendlyError(raw: string | number): string {
|
function friendlyError(raw: string | number): string {
|
||||||
if (typeof raw === "number") {
|
if (typeof raw === "number") {
|
||||||
|
|
@ -33,27 +45,52 @@ function friendlyError(raw: string | number): string {
|
||||||
|
|
||||||
const AUTO_DISMISS_MS = 3000;
|
const AUTO_DISMISS_MS = 3000;
|
||||||
|
|
||||||
|
function StepIcon({ status }: { status: string }) {
|
||||||
|
if (status === "complete") {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className="step-icon step-icon-done"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
aria-label="Step complete"
|
||||||
|
>
|
||||||
|
<circle cx="8" cy="8" r="7" stroke="#4ade80" strokeWidth="1.5" />
|
||||||
|
<path
|
||||||
|
d="M5 8.5l2 2 4-4.5"
|
||||||
|
stroke="#4ade80"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="step-spinner" />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SuggestionPage() {
|
export default function SuggestionPage() {
|
||||||
|
const api = useElectronAPI();
|
||||||
const [suggestion, setSuggestion] = useState("");
|
const [suggestion, setSuggestion] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isDesktop, setIsDesktop] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [steps, setSteps] = useState<AgentStep[]>([]);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const isDesktop = !!api?.onAutocompleteContext;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI?.onAutocompleteContext) {
|
if (!api?.onAutocompleteContext) {
|
||||||
setIsDesktop(false);
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [api]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!error) return;
|
if (!error) return;
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
window.electronAPI?.dismissSuggestion?.();
|
api?.dismissSuggestion?.();
|
||||||
}, AUTO_DISMISS_MS);
|
}, AUTO_DISMISS_MS);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [error]);
|
}, [error, api]);
|
||||||
|
|
||||||
const fetchSuggestion = useCallback(
|
const fetchSuggestion = useCallback(
|
||||||
async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => {
|
async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => {
|
||||||
|
|
@ -64,8 +101,13 @@ export default function SuggestionPage() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setSuggestion("");
|
setSuggestion("");
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSteps([]);
|
||||||
|
|
||||||
const token = getBearerToken();
|
let token = getBearerToken();
|
||||||
|
if (!token) {
|
||||||
|
await ensureTokensFromElectron();
|
||||||
|
token = getBearerToken();
|
||||||
|
}
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError(friendlyError("not authenticated"));
|
setError(friendlyError("not authenticated"));
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
|
@ -127,6 +169,17 @@ export default function SuggestionPage() {
|
||||||
setSuggestion((prev) => prev + parsed.delta);
|
setSuggestion((prev) => prev + parsed.delta);
|
||||||
} else if (parsed.type === "error") {
|
} else if (parsed.type === "error") {
|
||||||
setError(friendlyError(parsed.errorText));
|
setError(friendlyError(parsed.errorText));
|
||||||
|
} else if (parsed.type === "data-thinking-step") {
|
||||||
|
const { id, title, status, items } = parsed.data;
|
||||||
|
setSteps((prev) => {
|
||||||
|
const existing = prev.findIndex((s) => s.id === id);
|
||||||
|
if (existing >= 0) {
|
||||||
|
const updated = [...prev];
|
||||||
|
updated[existing] = { id, title, status, items };
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
return [...prev, { id, title, status, items }];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
@ -143,9 +196,9 @@ export default function SuggestionPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI?.onAutocompleteContext) return;
|
if (!api?.onAutocompleteContext) return;
|
||||||
|
|
||||||
const cleanup = window.electronAPI.onAutocompleteContext((data) => {
|
const cleanup = api.onAutocompleteContext((data) => {
|
||||||
const searchSpaceId = data.searchSpaceId || "1";
|
const searchSpaceId = data.searchSpaceId || "1";
|
||||||
if (data.screenshot) {
|
if (data.screenshot) {
|
||||||
fetchSuggestion(data.screenshot, searchSpaceId, data.appName, data.windowTitle);
|
fetchSuggestion(data.screenshot, searchSpaceId, data.appName, data.windowTitle);
|
||||||
|
|
@ -153,7 +206,7 @@ export default function SuggestionPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
}, [fetchSuggestion]);
|
}, [fetchSuggestion, api]);
|
||||||
|
|
||||||
if (!isDesktop) {
|
if (!isDesktop) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -173,13 +226,33 @@ export default function SuggestionPage() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading && !suggestion) {
|
const showLoading = isLoading && !suggestion;
|
||||||
|
|
||||||
|
if (showLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="suggestion-tooltip">
|
<div className="suggestion-tooltip">
|
||||||
<div className="suggestion-loading">
|
<div className="agent-activity">
|
||||||
<span className="suggestion-dot" />
|
{steps.length === 0 && (
|
||||||
<span className="suggestion-dot" />
|
<div className="activity-initial">
|
||||||
<span className="suggestion-dot" />
|
<span className="step-spinner" />
|
||||||
|
<span className="activity-label">Preparing…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{steps.length > 0 && (
|
||||||
|
<div className="activity-steps">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<div key={step.id} className="activity-step">
|
||||||
|
<StepIcon status={step.status} />
|
||||||
|
<span className="step-label">
|
||||||
|
{step.title}
|
||||||
|
{step.items.length > 0 && (
|
||||||
|
<span className="step-detail"> · {step.items[0]}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -187,12 +260,12 @@ export default function SuggestionPage() {
|
||||||
|
|
||||||
const handleAccept = () => {
|
const handleAccept = () => {
|
||||||
if (suggestion) {
|
if (suggestion) {
|
||||||
window.electronAPI?.acceptSuggestion?.(suggestion);
|
api?.acceptSuggestion?.(suggestion);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDismiss = () => {
|
const handleDismiss = () => {
|
||||||
window.electronAPI?.dismissSuggestion?.();
|
api?.dismissSuggestion?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!suggestion) return null;
|
if (!suggestion) return null;
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,21 @@ body:has(.suggestion-body) {
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-tooltip {
|
.suggestion-tooltip {
|
||||||
|
box-sizing: border-box;
|
||||||
background: #1e1e1e;
|
background: #1e1e1e;
|
||||||
border: 1px solid #3c3c3c;
|
border: 1px solid #3c3c3c;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
|
/* MAX_HEIGHT in suggestion-window.ts is 400px. Subtract 8px for margin
|
||||||
|
(4px * 2) so the tooltip + margin fits within the Electron window.
|
||||||
|
box-sizing: border-box ensures padding + border are included. */
|
||||||
|
max-height: 392px;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-text {
|
.suggestion-text {
|
||||||
|
|
@ -35,6 +43,26 @@ body:has(.suggestion-body) {
|
||||||
margin: 0 0 6px 0;
|
margin: 0 0 6px 0;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-text::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-text::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-text::-webkit-scrollbar-thumb {
|
||||||
|
background: #555;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestion-text::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #777;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-actions {
|
.suggestion-actions {
|
||||||
|
|
@ -43,6 +71,7 @@ body:has(.suggestion-body) {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
border-top: 1px solid #2a2a2a;
|
border-top: 1px solid #2a2a2a;
|
||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-btn {
|
.suggestion-btn {
|
||||||
|
|
@ -88,38 +117,77 @@ body:has(.suggestion-body) {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-loading {
|
/* --- Agent activity indicator --- */
|
||||||
|
|
||||||
|
.agent-activity {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 5px;
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-initial {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-dot {
|
.activity-label {
|
||||||
width: 4px;
|
color: #a1a1aa;
|
||||||
height: 4px;
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-steps {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
color: #d4d4d4;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-detail {
|
||||||
|
color: #71717a;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner (in_progress) */
|
||||||
|
.step-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1.5px solid #3f3f46;
|
||||||
|
border-top-color: #a78bfa;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #666;
|
animation: step-spin 0.7s linear infinite;
|
||||||
animation: suggestion-pulse 1.2s infinite ease-in-out;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-dot:nth-child(2) {
|
/* Checkmark icon (complete) */
|
||||||
animation-delay: 0.15s;
|
.step-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suggestion-dot:nth-child(3) {
|
@keyframes step-spin {
|
||||||
animation-delay: 0.3s;
|
to {
|
||||||
}
|
transform: rotate(360deg);
|
||||||
|
|
||||||
@keyframes suggestion-pulse {
|
|
||||||
0%,
|
|
||||||
80%,
|
|
||||||
100% {
|
|
||||||
opacity: 0.3;
|
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { ZeroProvider } from "@/components/providers/ZeroProvider";
|
||||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { LocaleProvider } from "@/contexts/LocaleContext";
|
import { LocaleProvider } from "@/contexts/LocaleContext";
|
||||||
|
import { PlatformProvider } from "@/contexts/platform-context";
|
||||||
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
|
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -139,15 +140,17 @@ export default function RootLayout({
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
>
|
>
|
||||||
<RootProvider>
|
<PlatformProvider>
|
||||||
<ReactQueryClientProvider>
|
<RootProvider>
|
||||||
<ZeroProvider>
|
<ReactQueryClientProvider>
|
||||||
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
|
<ZeroProvider>
|
||||||
</ZeroProvider>
|
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
|
||||||
</ReactQueryClientProvider>
|
</ZeroProvider>
|
||||||
<Toaster />
|
</ReactQueryClientProvider>
|
||||||
<AnnouncementToastProvider />
|
<Toaster />
|
||||||
</RootProvider>
|
<AnnouncementToastProvider />
|
||||||
|
</RootProvider>
|
||||||
|
</PlatformProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</LocaleProvider>
|
</LocaleProvider>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { logout } from "@/lib/auth-utils";
|
import { getLoginPath, logout } from "@/lib/auth-utils";
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
|
|
||||||
export function UserDropdown({
|
export function UserDropdown({
|
||||||
|
|
@ -33,22 +33,19 @@ export function UserDropdown({
|
||||||
if (isLoggingOut) return;
|
if (isLoggingOut) return;
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
try {
|
try {
|
||||||
// Track logout event and reset PostHog identity
|
|
||||||
trackLogout();
|
trackLogout();
|
||||||
resetUser();
|
resetUser();
|
||||||
|
|
||||||
// Revoke refresh token on server and clear all tokens from localStorage
|
|
||||||
await logout();
|
await logout();
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.href = "/";
|
window.location.href = getLoginPath();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during logout:", error);
|
console.error("Error during logout:", error);
|
||||||
// Even if there's an error, try to clear tokens and redirect
|
|
||||||
await logout();
|
await logout();
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.location.href = "/";
|
window.location.href = getLoginPath();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ import {
|
||||||
} from "@/components/ui/drawer";
|
} from "@/components/ui/drawer";
|
||||||
import { useComments } from "@/hooks/use-comments";
|
import { useComments } from "@/hooks/use-comments";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// Dynamically import video presentation tool to avoid loading Babel and Remotion in main bundle
|
// Dynamically import video presentation tool to avoid loading Babel and Remotion in main bundle
|
||||||
|
|
@ -463,16 +464,17 @@ export const AssistantMessage: FC = () => {
|
||||||
const AssistantActionBar: FC = () => {
|
const AssistantActionBar: FC = () => {
|
||||||
const isLast = useAuiState((s) => s.message.isLast);
|
const isLast = useAuiState((s) => s.message.isLast);
|
||||||
const aui = useAui();
|
const aui = useAui();
|
||||||
|
const api = useElectronAPI();
|
||||||
const [quickAskMode, setQuickAskMode] = useState("");
|
const [quickAskMode, setQuickAskMode] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLast || !window.electronAPI?.getQuickAskMode) return;
|
if (!isLast || !api?.getQuickAskMode) return;
|
||||||
window.electronAPI.getQuickAskMode().then((mode) => {
|
api.getQuickAskMode().then((mode) => {
|
||||||
if (mode) setQuickAskMode(mode);
|
if (mode) setQuickAskMode(mode);
|
||||||
});
|
});
|
||||||
}, [isLast]);
|
}, [isLast, api]);
|
||||||
|
|
||||||
const isTransform = isLast && !!window.electronAPI?.replaceText && quickAskMode === "transform";
|
const isTransform = isLast && !!api?.replaceText && quickAskMode === "transform";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ActionBarPrimitive.Root
|
<ActionBarPrimitive.Root
|
||||||
|
|
@ -508,7 +510,7 @@ const AssistantActionBar: FC = () => {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const text = aui.message().getCopyText();
|
const text = aui.message().getCopyText();
|
||||||
window.electronAPI?.replaceText(text);
|
api?.replaceText(text);
|
||||||
}}
|
}}
|
||||||
className="ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
className="ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Search } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
import { isSelfHosted } from "@/lib/env-config";
|
import { isSelfHosted } from "@/lib/env-config";
|
||||||
import { ConnectorCard } from "../components/connector-card";
|
import { ConnectorCard } from "../components/connector-card";
|
||||||
import {
|
import {
|
||||||
|
|
@ -75,9 +76,8 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
onManage,
|
onManage,
|
||||||
onViewAccountsList,
|
onViewAccountsList,
|
||||||
}) => {
|
}) => {
|
||||||
// Check if self-hosted mode (for showing self-hosted only connectors)
|
|
||||||
const selfHosted = isSelfHosted();
|
const selfHosted = isSelfHosted();
|
||||||
const isDesktop = typeof window !== "undefined" && !!window.electronAPI;
|
const { isDesktop } = usePlatform();
|
||||||
|
|
||||||
const matchesSearch = (title: string, description: string) =>
|
const matchesSearch = (title: string, description: string) =>
|
||||||
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export interface MentionedDocument {
|
||||||
export interface InlineMentionEditorRef {
|
export interface InlineMentionEditorRef {
|
||||||
focus: () => void;
|
focus: () => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
|
setText: (text: string) => void;
|
||||||
getText: () => string;
|
getText: () => string;
|
||||||
getMentionedDocuments: () => MentionedDocument[];
|
getMentionedDocuments: () => MentionedDocument[];
|
||||||
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
||||||
|
|
@ -397,6 +398,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Replace editor content with plain text and place cursor at end
|
||||||
|
const setText = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
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(
|
const setDocumentChipStatus = useCallback(
|
||||||
(
|
(
|
||||||
docId: number,
|
docId: number,
|
||||||
|
|
@ -469,6 +483,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => editorRef.current?.focus(),
|
focus: () => editorRef.current?.focus(),
|
||||||
clear,
|
clear,
|
||||||
|
setText,
|
||||||
getText,
|
getText,
|
||||||
getMentionedDocuments,
|
getMentionedDocuments,
|
||||||
insertDocumentChip,
|
insertDocumentChip,
|
||||||
|
|
|
||||||
|
|
@ -89,17 +89,10 @@ import type { Document } from "@/contracts/types/document.types";
|
||||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||||
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Placeholder texts that cycle in new chats when input is empty */
|
const COMPOSER_PLACEHOLDER = "Ask anything · Type / for prompts · Type @ to mention docs";
|
||||||
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",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Thread: FC = () => {
|
export const Thread: FC = () => {
|
||||||
return <ThreadContent />;
|
return <ThreadContent />;
|
||||||
|
|
@ -362,45 +355,24 @@ const Composer: FC = () => {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const electronAPI = useElectronAPI();
|
||||||
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
|
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
|
||||||
const clipboardLoadedRef = useRef(false);
|
const clipboardLoadedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.electronAPI || clipboardLoadedRef.current) return;
|
if (!electronAPI || clipboardLoadedRef.current) return;
|
||||||
clipboardLoadedRef.current = true;
|
clipboardLoadedRef.current = true;
|
||||||
window.electronAPI.getQuickAskText().then((text) => {
|
electronAPI.getQuickAskText().then((text) => {
|
||||||
if (text) {
|
if (text) {
|
||||||
setClipboardInitialText(text);
|
setClipboardInitialText(text);
|
||||||
setShowPromptPicker(true);
|
setShowPromptPicker(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, [electronAPI]);
|
||||||
|
|
||||||
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
|
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
|
||||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||||
|
|
||||||
// Cycling placeholder state - only cycles in new chats
|
const currentPlaceholder = COMPOSER_PLACEHOLDER;
|
||||||
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];
|
|
||||||
|
|
||||||
// Live collaboration state
|
// Live collaboration state
|
||||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
|
|
@ -504,34 +476,28 @@ const Composer: FC = () => {
|
||||||
: userText
|
: userText
|
||||||
? `${action.prompt}\n\n${userText}`
|
? `${action.prompt}\n\n${userText}`
|
||||||
: action.prompt;
|
: action.prompt;
|
||||||
|
editorRef.current?.setText(finalPrompt);
|
||||||
aui.composer().setText(finalPrompt);
|
aui.composer().setText(finalPrompt);
|
||||||
aui.composer().send();
|
|
||||||
editorRef.current?.clear();
|
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
setMentionedDocuments([]);
|
|
||||||
setSidebarDocs([]);
|
|
||||||
},
|
},
|
||||||
[actionQuery, aui, setMentionedDocuments, setSidebarDocs]
|
[actionQuery, aui]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleQuickAskSelect = useCallback(
|
const handleQuickAskSelect = useCallback(
|
||||||
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
||||||
if (!clipboardInitialText) return;
|
if (!clipboardInitialText) return;
|
||||||
window.electronAPI?.setQuickAskMode(action.mode);
|
electronAPI?.setQuickAskMode(action.mode);
|
||||||
const finalPrompt = action.prompt.includes("{selection}")
|
const finalPrompt = action.prompt.includes("{selection}")
|
||||||
? action.prompt.replace("{selection}", () => clipboardInitialText)
|
? action.prompt.replace("{selection}", () => clipboardInitialText)
|
||||||
: `${action.prompt}\n\n${clipboardInitialText}`;
|
: `${action.prompt}\n\n${clipboardInitialText}`;
|
||||||
|
editorRef.current?.setText(finalPrompt);
|
||||||
aui.composer().setText(finalPrompt);
|
aui.composer().setText(finalPrompt);
|
||||||
aui.composer().send();
|
|
||||||
editorRef.current?.clear();
|
|
||||||
setShowPromptPicker(false);
|
setShowPromptPicker(false);
|
||||||
setActionQuery("");
|
setActionQuery("");
|
||||||
setClipboardInitialText(undefined);
|
setClipboardInitialText(undefined);
|
||||||
setMentionedDocuments([]);
|
|
||||||
setSidebarDocs([]);
|
|
||||||
},
|
},
|
||||||
[clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs]
|
[clipboardInitialText, electronAPI, aui]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
|
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
|
||||||
|
|
|
||||||
158
surfsense_web/components/desktop/shortcut-recorder.tsx
Normal file
158
surfsense_web/components/desktop/shortcut-recorder.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
"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 = {
|
||||||
|
quickAsk: "CommandOrControl+Alt+S",
|
||||||
|
autocomplete: "CommandOrControl+Shift+Space",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Kbd pill component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function Kbd({ keys, className }: { keys: string[]; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span className={cn("inline-flex items-center gap-1", className)}>
|
||||||
|
{keys.map((key) => (
|
||||||
|
<kbd
|
||||||
|
key={key}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-7 min-w-7 items-center justify-center rounded-md border bg-muted px-1.5 font-mono text-xs font-medium text-muted-foreground shadow-sm",
|
||||||
|
key.length > 3 && "px-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<HTMLButtonElement>(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 (
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-lg border bg-background p-3">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium leading-none">{label}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground truncate">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{!isDefault && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7"
|
||||||
|
onClick={onReset}
|
||||||
|
title="Reset to default"
|
||||||
|
>
|
||||||
|
<RotateCcw />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
ref={inputRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRecording(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={() => setRecording(false)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 items-center gap-1 rounded-md border px-3 text-sm transition-all focus:outline-none",
|
||||||
|
recording
|
||||||
|
? "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||||
|
: "border-input bg-muted/50 hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{recording ? (
|
||||||
|
<span className="text-xs text-primary animate-pulse">Press keys...</span>
|
||||||
|
) : (
|
||||||
|
<Kbd keys={displayKeys} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,39 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
import { Monitor } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import dynamic from "next/dynamic";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type React from "react";
|
import React, { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import Balancer from "react-wrap-balancer";
|
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 { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
|
||||||
import { trackLoginAttempt } from "@/lib/posthog/events";
|
import { trackLoginAttempt } from "@/lib/posthog/events";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const HeroCarousel = dynamic(
|
|
||||||
() => import("@/components/ui/hero-carousel").then((m) => ({ default: m.HeroCarousel })),
|
|
||||||
{
|
|
||||||
ssr: false,
|
|
||||||
loading: () => (
|
|
||||||
<div className="w-full py-4 sm:py-8">
|
|
||||||
<div className="mx-auto w-full max-w-[900px]">
|
|
||||||
<div className="overflow-hidden rounded-2xl border border-neutral-200/60 bg-white shadow-xl sm:rounded-3xl dark:border-neutral-700/60 dark:bg-neutral-900">
|
|
||||||
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="h-5 w-32 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700" />
|
|
||||||
<div className="mt-2 h-4 w-64 animate-pulse rounded bg-neutral-100 dark:bg-neutral-800" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950">
|
|
||||||
<div className="aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Official Google "G" logo with brand colors
|
|
||||||
const GoogleLogo = ({ className }: { className?: string }) => (
|
const GoogleLogo = ({ className }: { className?: string }) => (
|
||||||
<svg
|
<svg
|
||||||
className={className}
|
className={className}
|
||||||
|
|
@ -62,87 +38,99 @@ const GoogleLogo = ({ className }: { className?: string }) => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
function useIsDesktop(breakpoint = 1024) {
|
const TAB_ITEMS = [
|
||||||
const [isDesktop, setIsDesktop] = useState(false);
|
{
|
||||||
useEffect(() => {
|
title: "Connect & Sync",
|
||||||
const mql = window.matchMedia(`(min-width: ${breakpoint}px)`);
|
description:
|
||||||
setIsDesktop(mql.matches);
|
"Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.",
|
||||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
src: "/homepage/hero_tutorial/ConnectorFlowGif.mp4",
|
||||||
mql.addEventListener("change", handler);
|
featured: true,
|
||||||
return () => mql.removeEventListener("change", handler);
|
},
|
||||||
}, [breakpoint]);
|
{
|
||||||
return isDesktop;
|
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() {
|
export function HeroSection() {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const isDesktop = useIsDesktop();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="mx-auto w-full max-w-7xl min-w-0 pt-36">
|
||||||
ref={parentRef}
|
<div className="mt-4 flex w-full min-w-0 flex-col items-start px-2 md:px-8 xl:px-0">
|
||||||
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-24 md:px-8 md:py-48"
|
<h1
|
||||||
>
|
className={cn(
|
||||||
<BackgroundGrids />
|
"relative mt-4 max-w-7xl text-left text-4xl font-bold tracking-tight text-balance text-neutral-900 sm:text-5xl md:text-6xl xl:text-8xl dark:text-neutral-50"
|
||||||
{isDesktop && (
|
)}
|
||||||
<>
|
>
|
||||||
<CollisionMechanism
|
<Balancer>NotebookLM for Teams</Balancer>
|
||||||
parentRef={parentRef}
|
</h1>
|
||||||
beamOptions={{
|
<div className="mt-4 flex w-full flex-col items-start justify-between gap-4 md:mt-12 md:flex-row md:items-end md:gap-10">
|
||||||
initialX: -400,
|
<div>
|
||||||
translateX: 600,
|
<h2
|
||||||
duration: 7,
|
className={cn(
|
||||||
repeatDelay: 3,
|
"relative mb-8 max-w-2xl text-left text-sm tracking-wide text-neutral-600 antialiased sm:text-base md:text-xl dark:text-neutral-400"
|
||||||
}}
|
)}
|
||||||
/>
|
>
|
||||||
<CollisionMechanism
|
An open source, privacy focused alternative to NotebookLM for teams with no data
|
||||||
parentRef={parentRef}
|
limits.
|
||||||
beamOptions={{
|
</h2>
|
||||||
initialX: -200,
|
|
||||||
translateX: 800,
|
|
||||||
duration: 4,
|
|
||||||
repeatDelay: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CollisionMechanism
|
|
||||||
parentRef={parentRef}
|
|
||||||
beamOptions={{
|
|
||||||
initialX: 200,
|
|
||||||
translateX: 1200,
|
|
||||||
duration: 5,
|
|
||||||
repeatDelay: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<CollisionMechanism
|
|
||||||
parentRef={parentRef}
|
|
||||||
beamOptions={{
|
|
||||||
initialX: 400,
|
|
||||||
translateX: 1400,
|
|
||||||
duration: 6,
|
|
||||||
repeatDelay: 3,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h2 className="relative z-50 mx-auto mb-4 mt-8 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
|
<div className="relative mb-4 flex w-full flex-col justify-center gap-y-2 sm:flex-row sm:justify-start sm:space-y-0 sm:space-x-4">
|
||||||
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
<GetStartedButton />
|
||||||
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
</div>
|
||||||
<Balancer>NotebookLM for Teams</Balancer>
|
|
||||||
</div>
|
</div>
|
||||||
|
<DownloadApp />
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
<BrowserWindow />
|
||||||
<p className="relative z-50 mx-auto mt-4 max-w-lg px-6 text-center text-sm leading-relaxed text-gray-600 sm:text-base sm:leading-relaxed md:max-w-xl md:text-lg md:leading-relaxed dark:text-gray-200">
|
|
||||||
Connect any LLM to your internal knowledge sources and chat with it in real time alongside
|
|
||||||
your team.
|
|
||||||
</p>
|
|
||||||
<div className="mb-6 mt-6 flex w-full flex-col items-center justify-center gap-4 px-8 sm:flex-row md:mb-10">
|
|
||||||
<GetStartedButton />
|
|
||||||
{/* <ContactSalesButton /> */}
|
|
||||||
</div>
|
|
||||||
<div ref={containerRef} className="relative w-full z-51">
|
|
||||||
<HeroCarousel />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -158,256 +146,247 @@ function GetStartedButton() {
|
||||||
|
|
||||||
if (isGoogleAuth) {
|
if (isGoogleAuth) {
|
||||||
return (
|
return (
|
||||||
<motion.button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleGoogleLogin}
|
onClick={handleGoogleLogin}
|
||||||
whileHover="hover"
|
className="flex h-14 w-full cursor-pointer items-center justify-center gap-3 rounded-lg bg-white text-center text-base font-medium text-neutral-700 shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 hover:bg-neutral-50 sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 dark:hover:bg-neutral-800"
|
||||||
whileTap={{ scale: 0.98 }}
|
|
||||||
initial="idle"
|
|
||||||
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-3 overflow-hidden rounded-xl bg-white px-6 py-2.5 text-sm font-semibold text-neutral-700 shadow-lg ring-1 ring-neutral-200/50 transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
|
|
||||||
variants={{
|
|
||||||
idle: { scale: 1, y: 0 },
|
|
||||||
hover: { scale: 1.02, y: -2 },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Animated gradient background on hover */}
|
<GoogleLogo className="h-5 w-5" />
|
||||||
<motion.div
|
<span>Continue with Google</span>
|
||||||
className="absolute inset-0 bg-linear-to-r from-blue-50 via-green-50 to-yellow-50 dark:from-blue-950/30 dark:via-green-950/30 dark:to-yellow-950/30"
|
</button>
|
||||||
variants={{
|
|
||||||
idle: { opacity: 0 },
|
|
||||||
hover: { opacity: 1 },
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
/>
|
|
||||||
{/* Google logo with subtle animation */}
|
|
||||||
<motion.div
|
|
||||||
className="relative"
|
|
||||||
variants={{
|
|
||||||
idle: { rotate: 0 },
|
|
||||||
hover: { rotate: [0, -8, 8, 0] },
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.4, ease: "easeInOut" }}
|
|
||||||
>
|
|
||||||
<GoogleLogo className="h-5 w-5" />
|
|
||||||
</motion.div>
|
|
||||||
<span className="relative">Continue with Google</span>
|
|
||||||
</motion.button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
|
<Link
|
||||||
<Link
|
href="/login"
|
||||||
href="/login"
|
className="flex h-14 w-full items-center justify-center rounded-lg bg-black text-center text-base font-medium text-white shadow-sm ring-1 shadow-black/10 ring-black/10 transition duration-150 active:scale-98 sm:w-52 dark:bg-white dark:text-black"
|
||||||
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-black px-6 py-2.5 text-sm font-semibold text-white shadow-lg transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-white dark:text-black"
|
>
|
||||||
>
|
Get Started
|
||||||
Get Started
|
</Link>
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const BackgroundGrids = () => {
|
const BrowserWindow = () => {
|
||||||
return (
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
<div className="pointer-events-none absolute inset-0 z-0 grid h-screen w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
|
const selectedItem = TAB_ITEMS[selectedIndex];
|
||||||
<div className="relative h-full w-full">
|
const { expanded, open, close } = useExpandedMedia();
|
||||||
<GridLineVertical className="left-0" />
|
|
||||||
<GridLineVertical className="left-auto right-0" />
|
|
||||||
</div>
|
|
||||||
<div className="relative h-full w-full">
|
|
||||||
<GridLineVertical className="left-0" />
|
|
||||||
<GridLineVertical className="left-auto right-0" />
|
|
||||||
</div>
|
|
||||||
<div className="relative h-full w-full bg-linear-to-b from-transparent via-neutral-100 to-transparent dark:via-neutral-800">
|
|
||||||
<GridLineVertical className="left-0" />
|
|
||||||
<GridLineVertical className="left-auto right-0" />
|
|
||||||
</div>
|
|
||||||
<div className="relative h-full w-full">
|
|
||||||
<GridLineVertical className="left-0" />
|
|
||||||
<GridLineVertical className="left-auto right-0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CollisionMechanism = ({
|
|
||||||
parentRef,
|
|
||||||
beamOptions = {},
|
|
||||||
}: {
|
|
||||||
parentRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
beamOptions?: {
|
|
||||||
initialX?: number;
|
|
||||||
translateX?: number;
|
|
||||||
initialY?: number;
|
|
||||||
translateY?: number;
|
|
||||||
rotate?: number;
|
|
||||||
className?: string;
|
|
||||||
duration?: number;
|
|
||||||
delay?: number;
|
|
||||||
repeatDelay?: number;
|
|
||||||
};
|
|
||||||
}) => {
|
|
||||||
const beamRef = useRef<HTMLDivElement>(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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<motion.div
|
<motion.div className="relative my-4 flex w-full flex-col items-start justify-start overflow-hidden rounded-2xl shadow-2xl md:my-12">
|
||||||
key={beamKey}
|
<div className="flex w-full items-center justify-start overflow-hidden bg-gray-200 py-4 pl-4 dark:bg-neutral-800">
|
||||||
ref={beamRef}
|
<div className="mr-6 flex items-center gap-2">
|
||||||
animate="animate"
|
<div className="size-3 rounded-full bg-red-500" />
|
||||||
initial={{
|
<div className="size-3 rounded-full bg-yellow-500" />
|
||||||
translateY: beamOptions.initialY || "-200px",
|
<div className="size-3 rounded-full bg-green-500" />
|
||||||
translateX: beamOptions.initialX || "0px",
|
</div>
|
||||||
rotate: beamOptions.rotate || -45,
|
<div className="no-visible-scrollbar flex min-w-0 shrink flex-row items-center justify-start gap-2 overflow-x-auto mask-l-from-98% py-0.5 pr-2 pl-2 md:pl-4">
|
||||||
}}
|
{TAB_ITEMS.map((item, index) => (
|
||||||
variants={{
|
<React.Fragment key={item.title}>
|
||||||
animate: {
|
<button
|
||||||
translateY: beamOptions.translateY || "800px",
|
type="button"
|
||||||
translateX: beamOptions.translateX || "700px",
|
onClick={() => setSelectedIndex(index)}
|
||||||
rotate: beamOptions.rotate || -45,
|
className={cn(
|
||||||
},
|
"flex shrink-0 items-center gap-1.5 rounded-md px-2 py-1 text-xs transition duration-150 hover:bg-white sm:text-sm dark:hover:bg-neutral-950",
|
||||||
}}
|
selectedIndex === index &&
|
||||||
transition={{
|
!item.featured &&
|
||||||
duration: beamOptions.duration || 8,
|
"bg-white shadow ring-1 shadow-black/10 ring-black/10 dark:bg-neutral-900",
|
||||||
repeat: Infinity,
|
selectedIndex === index &&
|
||||||
repeatType: "loop",
|
item.featured &&
|
||||||
ease: "linear",
|
"bg-amber-50 shadow ring-1 shadow-amber-200/50 ring-amber-400/60 dark:bg-amber-950/40 dark:shadow-amber-900/30 dark:ring-amber-500/50",
|
||||||
delay: beamOptions.delay || 0,
|
item.featured &&
|
||||||
repeatDelay: beamOptions.repeatDelay || 0,
|
selectedIndex !== index &&
|
||||||
}}
|
"hover:bg-amber-50 dark:hover:bg-amber-950/30"
|
||||||
className={cn(
|
)}
|
||||||
"absolute left-96 top-20 m-auto h-14 w-px rounded-full bg-linear-to-t from-orange-500 via-yellow-500 to-transparent will-change-transform",
|
>
|
||||||
beamOptions.className
|
{item.title}
|
||||||
)}
|
{item.featured && (
|
||||||
/>
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-flex shrink-0 items-center justify-center rounded border border-amber-300 bg-amber-100 p-0.5 text-amber-700 dark:border-amber-700 dark:bg-amber-900/50 dark:text-amber-400">
|
||||||
|
<Monitor className="size-3" />
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Desktop app only</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{index !== TAB_ITEMS.length - 1 && (
|
||||||
|
<div className="h-4 w-px shrink-0 rounded-full bg-neutral-300 dark:bg-neutral-700" />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full overflow-hidden bg-gray-100/50 px-4 pt-4 perspective-distant dark:bg-neutral-950">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
initial={{
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.99,
|
||||||
|
filter: "blur(10px)",
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.98,
|
||||||
|
filter: "blur(10px)",
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 0.3,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
key={selectedItem.title}
|
||||||
|
className="relative overflow-hidden rounded-tl-xl rounded-tr-xl bg-white shadow-sm ring-1 shadow-black/10 ring-black/10 will-change-transform dark:bg-neutral-950"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 border-b border-neutral-200/60 px-4 py-3 sm:px-6 sm:py-4 dark:border-neutral-700/60">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="truncate text-base font-semibold text-neutral-900 sm:text-lg dark:text-white">
|
||||||
|
{selectedItem.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{selectedItem.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950 w-full"
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
|
<TabVideo src={selectedItem.src} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{collision.detected && collision.coordinates && (
|
{expanded && (
|
||||||
<Explosion
|
<ExpandedMediaOverlay src={selectedItem.src} alt={selectedItem.title} onClose={close} />
|
||||||
key={`${collision.coordinates.x}-${collision.coordinates.y}`}
|
|
||||||
className=""
|
|
||||||
style={{
|
|
||||||
left: `${collision.coordinates.x + 20}px`,
|
|
||||||
top: `${collision.coordinates.y}px`,
|
|
||||||
transform: "translate(-50%, -50%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Explosion = ({ ...props }: React.HTMLProps<HTMLDivElement>) => {
|
const TabVideo = memo(function TabVideo({ src }: { src: string }) {
|
||||||
const spans = Array.from({ length: 20 }, (_, index) => ({
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
id: index,
|
const [hasLoaded, setHasLoaded] = useState(false);
|
||||||
initialX: 0,
|
|
||||||
initialY: 0,
|
useEffect(() => {
|
||||||
directionX: Math.floor(Math.random() * 80 - 40),
|
setHasLoaded(false);
|
||||||
directionY: Math.floor(Math.random() * -50 - 10),
|
const video = videoRef.current;
|
||||||
}));
|
if (!video) return;
|
||||||
|
video.currentTime = 0;
|
||||||
|
video.play().catch(() => {});
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
const handleCanPlay = useCallback(() => {
|
||||||
|
setHasLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...props} className={cn("absolute z-50 h-2 w-2", props.className)}>
|
<div className="relative">
|
||||||
<motion.div
|
<video
|
||||||
initial={{ opacity: 0 }}
|
ref={videoRef}
|
||||||
animate={{ opacity: [0, 1, 0] }}
|
key={src}
|
||||||
exit={{ opacity: 0 }}
|
src={src}
|
||||||
transition={{ duration: 1, ease: "easeOut" }}
|
preload="auto"
|
||||||
className="absolute -inset-x-10 top-0 m-auto h-[4px] w-10 rounded-full bg-linear-to-r from-transparent via-orange-500 to-transparent blur-sm"
|
loop
|
||||||
></motion.div>
|
muted
|
||||||
{spans.map((span) => (
|
playsInline
|
||||||
<motion.span
|
onCanPlay={handleCanPlay}
|
||||||
key={span.id}
|
className="aspect-video w-full rounded-lg sm:rounded-xl"
|
||||||
initial={{ x: span.initialX, y: span.initialY, opacity: 1 }}
|
/>
|
||||||
animate={{ x: span.directionX, y: span.directionY, opacity: 0 }}
|
{!hasLoaded && (
|
||||||
transition={{ duration: Math.random() * 1.5 + 0.5, ease: "easeOut" }}
|
<div className="absolute inset-0 aspect-video w-full animate-pulse rounded-lg bg-neutral-100 sm:rounded-xl dark:bg-neutral-800" />
|
||||||
className="absolute h-1 w-1 rounded-full bg-linear-to-b from-orange-500 to-yellow-500"
|
)}
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => {
|
const GITHUB_RELEASES_URL = "https://github.com/MODSetter/SurfSense/releases/latest";
|
||||||
|
|
||||||
|
const DownloadApp = memo(function DownloadApp() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex flex-col items-start justify-start">
|
||||||
style={
|
<p className="mb-4 text-left text-sm text-neutral-500 lg:text-lg dark:text-neutral-400">
|
||||||
{
|
Download the desktop app
|
||||||
"--background": "#ffffff",
|
</p>
|
||||||
"--color": "rgba(0, 0, 0, 0.2)",
|
<div className="mb-2 flex flex-row flex-wrap items-center gap-3">
|
||||||
"--height": "5px",
|
<a
|
||||||
"--width": "1px",
|
href={GITHUB_RELEASES_URL}
|
||||||
"--fade-stop": "90%",
|
target="_blank"
|
||||||
"--offset": offset || "150px", //-100px if you want to keep the line inside
|
rel="noopener noreferrer"
|
||||||
"--color-dark": "rgba(255, 255, 255, 0.3)",
|
className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||||
maskComposite: "exclude",
|
>
|
||||||
} as React.CSSProperties
|
<svg
|
||||||
}
|
className="size-4"
|
||||||
className={cn(
|
viewBox="0 0 24 24"
|
||||||
"absolute top-[calc(var(--offset)/2*-1)] h-[calc(100%+var(--offset))] w-(--width)",
|
fill="none"
|
||||||
"bg-[linear-gradient(to_bottom,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
stroke="currentColor"
|
||||||
"bg-size-[var(--width)_var(--height)]",
|
strokeWidth="2"
|
||||||
"[mask:linear-gradient(to_top,var(--background)_var(--fade-stop),transparent),linear-gradient(to_bottom,var(--background)_var(--fade-stop),transparent),linear-gradient(black,black)]",
|
strokeLinecap="round"
|
||||||
"mask-exclude",
|
strokeLinejoin="round"
|
||||||
"z-30",
|
aria-label="Download for macOS"
|
||||||
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
>
|
||||||
className
|
<path d="M12 17V3" />
|
||||||
)}
|
<path d="m6 11 6 6 6-6" />
|
||||||
></div>
|
<path d="M19 21H5" />
|
||||||
|
</svg>
|
||||||
|
macOS
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={GITHUB_RELEASES_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="size-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-label="Download for Windows"
|
||||||
|
>
|
||||||
|
<path d="M12 17V3" />
|
||||||
|
<path d="m6 11 6 6 6-6" />
|
||||||
|
<path d="M19 21H5" />
|
||||||
|
</svg>
|
||||||
|
Windows
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={GITHUB_RELEASES_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-neutral-200 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="size-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-label="Download for Linux"
|
||||||
|
>
|
||||||
|
<path d="M12 17V3" />
|
||||||
|
<path d="m6 11 6 6 6-6" />
|
||||||
|
<path d="M19 21H5" />
|
||||||
|
</svg>
|
||||||
|
Linux
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ import { useInbox } from "@/hooks/use-inbox";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { logout } from "@/lib/auth-utils";
|
import { getLoginPath, logout } from "@/lib/auth-utils";
|
||||||
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
@ -603,12 +603,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
||||||
await logout();
|
await logout();
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
router.push("/");
|
router.push(getLoginPath());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during logout:", error);
|
console.error("Error during logout:", error);
|
||||||
await logout();
|
await logout();
|
||||||
router.push("/");
|
router.push(getLoginPath());
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
|
|
@ -84,6 +85,7 @@ export function DocumentsSidebar({
|
||||||
const tSidebar = useTranslations("sidebar");
|
const tSidebar = useTranslations("sidebar");
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||||
|
const electronAPI = useElectronAPI();
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||||
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
||||||
|
|
@ -97,11 +99,11 @@ export function DocumentsSidebar({
|
||||||
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
|
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = typeof window !== "undefined" ? window.electronAPI : null;
|
if (!electronAPI?.getWatchedFolders) return;
|
||||||
if (!api?.getWatchedFolders) return;
|
const api = electronAPI;
|
||||||
|
|
||||||
async function loadWatchedIds() {
|
async function loadWatchedIds() {
|
||||||
const folders = await api!.getWatchedFolders();
|
const folders = await api.getWatchedFolders();
|
||||||
|
|
||||||
if (folders.length === 0) {
|
if (folders.length === 0) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -109,7 +111,7 @@ export function DocumentsSidebar({
|
||||||
for (const bf of backendFolders) {
|
for (const bf of backendFolders) {
|
||||||
const meta = bf.metadata as Record<string, unknown> | null;
|
const meta = bf.metadata as Record<string, unknown> | null;
|
||||||
if (!meta?.watched || !meta.folder_path) continue;
|
if (!meta?.watched || !meta.folder_path) continue;
|
||||||
await api!.addWatchedFolder({
|
await api.addWatchedFolder({
|
||||||
path: meta.folder_path as string,
|
path: meta.folder_path as string,
|
||||||
name: bf.name,
|
name: bf.name,
|
||||||
rootFolderId: bf.id,
|
rootFolderId: bf.id,
|
||||||
|
|
@ -119,7 +121,7 @@ export function DocumentsSidebar({
|
||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const recovered = await api!.getWatchedFolders();
|
const recovered = await api.getWatchedFolders();
|
||||||
const ids = new Set(
|
const ids = new Set(
|
||||||
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
|
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
|
||||||
);
|
);
|
||||||
|
|
@ -137,7 +139,7 @@ export function DocumentsSidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
loadWatchedIds();
|
loadWatchedIds();
|
||||||
}, [searchSpaceId]);
|
}, [searchSpaceId, electronAPI]);
|
||||||
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
||||||
|
|
||||||
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
|
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
|
||||||
|
|
@ -276,10 +278,9 @@ export function DocumentsSidebar({
|
||||||
|
|
||||||
const handleRescanFolder = useCallback(
|
const handleRescanFolder = useCallback(
|
||||||
async (folder: FolderDisplay) => {
|
async (folder: FolderDisplay) => {
|
||||||
const api = window.electronAPI;
|
if (!electronAPI) return;
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
const watchedFolders = await api.getWatchedFolders();
|
const watchedFolders = await electronAPI.getWatchedFolders();
|
||||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
toast.error("This folder is not being watched");
|
toast.error("This folder is not being watched");
|
||||||
|
|
@ -298,28 +299,30 @@ export function DocumentsSidebar({
|
||||||
toast.error((err as Error)?.message || "Failed to re-scan folder");
|
toast.error((err as Error)?.message || "Failed to re-scan folder");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[searchSpaceId]
|
[searchSpaceId, electronAPI]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStopWatching = useCallback(async (folder: FolderDisplay) => {
|
const handleStopWatching = useCallback(
|
||||||
const api = window.electronAPI;
|
async (folder: FolderDisplay) => {
|
||||||
if (!api) return;
|
if (!electronAPI) return;
|
||||||
|
|
||||||
const watchedFolders = await api.getWatchedFolders();
|
const watchedFolders = await electronAPI.getWatchedFolders();
|
||||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
toast.error("This folder is not being watched");
|
toast.error("This folder is not being watched");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.removeWatchedFolder(matched.path);
|
await electronAPI.removeWatchedFolder(matched.path);
|
||||||
try {
|
try {
|
||||||
await foldersApiService.stopWatching(folder.id);
|
await foldersApiService.stopWatching(folder.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
|
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
|
||||||
}
|
}
|
||||||
toast.success(`Stopped watching: ${matched.name}`);
|
toast.success(`Stopped watching: ${matched.name}`);
|
||||||
}, []);
|
},
|
||||||
|
[electronAPI]
|
||||||
|
);
|
||||||
|
|
||||||
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
|
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -330,23 +333,25 @@ export function DocumentsSidebar({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDeleteFolder = useCallback(async (folder: FolderDisplay) => {
|
const handleDeleteFolder = useCallback(
|
||||||
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
|
async (folder: FolderDisplay) => {
|
||||||
try {
|
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
|
||||||
const api = window.electronAPI;
|
try {
|
||||||
if (api) {
|
if (electronAPI) {
|
||||||
const watchedFolders = await api.getWatchedFolders();
|
const watchedFolders = await electronAPI.getWatchedFolders();
|
||||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||||
if (matched) {
|
if (matched) {
|
||||||
await api.removeWatchedFolder(matched.path);
|
await electronAPI.removeWatchedFolder(matched.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
await foldersApiService.deleteFolder(folder.id);
|
||||||
|
toast.success("Folder deleted");
|
||||||
|
} catch (e: unknown) {
|
||||||
|
toast.error((e as Error)?.message || "Failed to delete folder");
|
||||||
}
|
}
|
||||||
await foldersApiService.deleteFolder(folder.id);
|
},
|
||||||
toast.success("Folder deleted");
|
[electronAPI]
|
||||||
} catch (e: unknown) {
|
);
|
||||||
toast.error((e as Error)?.message || "Failed to delete folder");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMoveFolder = useCallback(
|
const handleMoveFolder = useCallback(
|
||||||
(folder: FolderDisplay) => {
|
(folder: FolderDisplay) => {
|
||||||
|
|
|
||||||
16
surfsense_web/components/platform-gate.tsx
Normal file
16
surfsense_web/components/platform-gate.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
|
|
||||||
|
export function DesktopOnly({ children }: { children: ReactNode }) {
|
||||||
|
const { isDesktop } = usePlatform();
|
||||||
|
if (!isDesktop) return null;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebOnly({ children }: { children: ReactNode }) {
|
||||||
|
const { isWeb } = usePlatform();
|
||||||
|
if (!isWeb) return null;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react";
|
import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useMemo } from "react";
|
||||||
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
|
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
|
||||||
import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent";
|
import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent";
|
||||||
import { DesktopContent } from "@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent";
|
import { DesktopContent } from "@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent";
|
||||||
|
|
@ -11,37 +12,42 @@ import { PromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/
|
||||||
import { PurchaseHistoryContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent";
|
import { PurchaseHistoryContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PurchaseHistoryContent";
|
||||||
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||||
import { SettingsDialog } from "@/components/settings/settings-dialog";
|
import { SettingsDialog } from "@/components/settings/settings-dialog";
|
||||||
|
import { usePlatform } from "@/hooks/use-platform";
|
||||||
|
|
||||||
export function UserSettingsDialog() {
|
export function UserSettingsDialog() {
|
||||||
const t = useTranslations("userSettings");
|
const t = useTranslations("userSettings");
|
||||||
const [state, setState] = useAtom(userSettingsDialogAtom);
|
const [state, setState] = useAtom(userSettingsDialogAtom);
|
||||||
|
const { isDesktop } = usePlatform();
|
||||||
|
|
||||||
const navItems = [
|
const navItems = useMemo(
|
||||||
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
|
() => [
|
||||||
{
|
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
|
||||||
value: "api-key",
|
{
|
||||||
label: t("api_key_nav_label"),
|
value: "api-key",
|
||||||
icon: <KeyRound className="h-4 w-4" />,
|
label: t("api_key_nav_label"),
|
||||||
},
|
icon: <KeyRound className="h-4 w-4" />,
|
||||||
{
|
},
|
||||||
value: "prompts",
|
{
|
||||||
label: "My Prompts",
|
value: "prompts",
|
||||||
icon: <Sparkles className="h-4 w-4" />,
|
label: "My Prompts",
|
||||||
},
|
icon: <Sparkles className="h-4 w-4" />,
|
||||||
{
|
},
|
||||||
value: "community-prompts",
|
{
|
||||||
label: "Community Prompts",
|
value: "community-prompts",
|
||||||
icon: <Globe className="h-4 w-4" />,
|
label: "Community Prompts",
|
||||||
},
|
icon: <Globe className="h-4 w-4" />,
|
||||||
{
|
},
|
||||||
value: "purchases",
|
{
|
||||||
label: "Purchase History",
|
value: "purchases",
|
||||||
icon: <Receipt className="h-4 w-4" />,
|
label: "Purchase History",
|
||||||
},
|
icon: <Receipt className="h-4 w-4" />,
|
||||||
...(typeof window !== "undefined" && window.electronAPI
|
},
|
||||||
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
|
...(isDesktop
|
||||||
: []),
|
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
|
||||||
];
|
: []),
|
||||||
|
],
|
||||||
|
[t, isDesktop]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsDialog
|
<SettingsDialog
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
import {
|
import {
|
||||||
trackDocumentUploadFailure,
|
trackDocumentUploadFailure,
|
||||||
|
|
@ -150,10 +151,11 @@ export function DocumentUploadTab({
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const electronAPI = useElectronAPI();
|
||||||
const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null);
|
const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null);
|
||||||
const [watchFolder, setWatchFolder] = useState(true);
|
const [watchFolder, setWatchFolder] = useState(true);
|
||||||
const [folderSubmitting, setFolderSubmitting] = useState(false);
|
const [folderSubmitting, setFolderSubmitting] = useState(false);
|
||||||
const isElectron = typeof window !== "undefined" && !!window.electronAPI?.browseFiles;
|
const isElectron = !!electronAPI?.browseFiles;
|
||||||
|
|
||||||
const acceptedFileTypes = useMemo(() => {
|
const acceptedFileTypes = useMemo(() => {
|
||||||
const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE;
|
const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE;
|
||||||
|
|
@ -215,33 +217,31 @@ export function DocumentUploadTab({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleBrowseFiles = useCallback(async () => {
|
const handleBrowseFiles = useCallback(async () => {
|
||||||
const api = window.electronAPI;
|
if (!electronAPI?.browseFiles) return;
|
||||||
if (!api?.browseFiles) return;
|
|
||||||
|
|
||||||
const paths = await api.browseFiles();
|
const paths = await electronAPI.browseFiles();
|
||||||
if (!paths || paths.length === 0) return;
|
if (!paths || paths.length === 0) return;
|
||||||
|
|
||||||
setSelectedFolder(null);
|
setSelectedFolder(null);
|
||||||
const fileDataList = await api.readLocalFiles(paths);
|
const fileDataList = await electronAPI.readLocalFiles(paths);
|
||||||
const newFiles: FileWithId[] = fileDataList.map((fd) => ({
|
const newFiles: FileWithId[] = fileDataList.map((fd) => ({
|
||||||
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
|
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
|
||||||
file: new File([fd.data], fd.name, { type: fd.mimeType }),
|
file: new File([fd.data], fd.name, { type: fd.mimeType }),
|
||||||
}));
|
}));
|
||||||
setFiles((prev) => [...prev, ...newFiles]);
|
setFiles((prev) => [...prev, ...newFiles]);
|
||||||
}, []);
|
}, [electronAPI]);
|
||||||
|
|
||||||
const handleBrowseFolder = useCallback(async () => {
|
const handleBrowseFolder = useCallback(async () => {
|
||||||
const api = window.electronAPI;
|
if (!electronAPI?.selectFolder) return;
|
||||||
if (!api?.selectFolder) return;
|
|
||||||
|
|
||||||
const folderPath = await api.selectFolder();
|
const folderPath = await electronAPI.selectFolder();
|
||||||
if (!folderPath) return;
|
if (!folderPath) return;
|
||||||
|
|
||||||
const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath;
|
const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath;
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
setSelectedFolder({ path: folderPath, name: folderName });
|
setSelectedFolder({ path: folderPath, name: folderName });
|
||||||
setWatchFolder(true);
|
setWatchFolder(true);
|
||||||
}, []);
|
}, [electronAPI]);
|
||||||
|
|
||||||
const handleFolderChange = useCallback(
|
const handleFolderChange = useCallback(
|
||||||
(e: ChangeEvent<HTMLInputElement>) => {
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -286,9 +286,7 @@ export function DocumentUploadTab({
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFolderSubmit = useCallback(async () => {
|
const handleFolderSubmit = useCallback(async () => {
|
||||||
if (!selectedFolder) return;
|
if (!selectedFolder || !electronAPI) return;
|
||||||
const api = window.electronAPI;
|
|
||||||
if (!api) return;
|
|
||||||
|
|
||||||
setFolderSubmitting(true);
|
setFolderSubmitting(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -303,7 +301,7 @@ export function DocumentUploadTab({
|
||||||
const rootFolderId = (result as { root_folder_id?: number })?.root_folder_id ?? null;
|
const rootFolderId = (result as { root_folder_id?: number })?.root_folder_id ?? null;
|
||||||
|
|
||||||
if (watchFolder) {
|
if (watchFolder) {
|
||||||
await api.addWatchedFolder({
|
await electronAPI.addWatchedFolder({
|
||||||
path: selectedFolder.path,
|
path: selectedFolder.path,
|
||||||
name: selectedFolder.name,
|
name: selectedFolder.name,
|
||||||
excludePatterns: [
|
excludePatterns: [
|
||||||
|
|
@ -331,7 +329,7 @@ export function DocumentUploadTab({
|
||||||
} finally {
|
} finally {
|
||||||
setFolderSubmitting(false);
|
setFolderSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [selectedFolder, watchFolder, searchSpaceId, shouldSummarize, onSuccess]);
|
}, [selectedFolder, watchFolder, searchSpaceId, shouldSummarize, onSuccess, electronAPI]);
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
|
|
|
||||||
29
surfsense_web/contexts/platform-context.tsx
Normal file
29
surfsense_web/contexts/platform-context.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, type ReactNode, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface PlatformContextValue {
|
||||||
|
isDesktop: boolean;
|
||||||
|
isWeb: boolean;
|
||||||
|
electronAPI: ElectronAPI | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SSR_VALUE: PlatformContextValue = {
|
||||||
|
isDesktop: false,
|
||||||
|
isWeb: false,
|
||||||
|
electronAPI: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PlatformContext = createContext<PlatformContextValue>(SSR_VALUE);
|
||||||
|
|
||||||
|
export function PlatformProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [value, setValue] = useState<PlatformContextValue>(SSR_VALUE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const api = window.electronAPI ?? null;
|
||||||
|
const isDesktop = !!api;
|
||||||
|
setValue({ isDesktop, isWeb: !isDesktop, electronAPI: api });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <PlatformContext.Provider value={value}>{children}</PlatformContext.Provider>;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
|
|
||||||
interface FileChangedEvent {
|
interface FileChangedEvent {
|
||||||
|
|
@ -29,6 +30,7 @@ interface BatchItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFolderSync() {
|
export function useFolderSync() {
|
||||||
|
const electronAPI = useElectronAPI();
|
||||||
const queueRef = useRef<BatchItem[]>([]);
|
const queueRef = useRef<BatchItem[]>([]);
|
||||||
const processingRef = useRef(false);
|
const processingRef = useRef(false);
|
||||||
const debounceTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
const debounceTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
|
@ -49,9 +51,8 @@ export function useFolderSync() {
|
||||||
target_file_paths: batch.filePaths,
|
target_file_paths: batch.filePaths,
|
||||||
root_folder_id: batch.rootFolderId,
|
root_folder_id: batch.rootFolderId,
|
||||||
});
|
});
|
||||||
const api = typeof window !== "undefined" ? window.electronAPI : null;
|
if (electronAPI?.acknowledgeFileEvents && batch.ackIds.length > 0) {
|
||||||
if (api?.acknowledgeFileEvents && batch.ackIds.length > 0) {
|
await electronAPI.acknowledgeFileEvents(batch.ackIds);
|
||||||
await api.acknowledgeFileEvents(batch.ackIds);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[FolderSync] Failed to trigger batch re-index:", err);
|
console.error("[FolderSync] Failed to trigger batch re-index:", err);
|
||||||
|
|
@ -117,25 +118,22 @@ export function useFolderSync() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMountedRef.current = true;
|
isMountedRef.current = true;
|
||||||
const api = typeof window !== "undefined" ? window.electronAPI : null;
|
if (!electronAPI?.onFileChanged) {
|
||||||
if (!api?.onFileChanged) {
|
|
||||||
return () => {
|
return () => {
|
||||||
isMountedRef.current = false;
|
isMountedRef.current = false;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signal to main process that the renderer is ready to receive events
|
electronAPI.signalRendererReady?.();
|
||||||
api.signalRendererReady?.();
|
|
||||||
|
|
||||||
// Drain durable outbox first so events survive renderer startup gaps and restarts
|
void electronAPI.getPendingFileEvents?.().then((pendingEvents) => {
|
||||||
void api.getPendingFileEvents?.().then((pendingEvents) => {
|
|
||||||
if (!isMountedRef.current || !pendingEvents?.length) return;
|
if (!isMountedRef.current || !pendingEvents?.length) return;
|
||||||
for (const event of pendingEvents) {
|
for (const event of pendingEvents) {
|
||||||
enqueueWithDebounce(event);
|
enqueueWithDebounce(event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const cleanup = api.onFileChanged((event: FileChangedEvent) => {
|
const cleanup = electronAPI.onFileChanged((event: FileChangedEvent) => {
|
||||||
enqueueWithDebounce(event);
|
enqueueWithDebounce(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -149,5 +147,5 @@ export function useFolderSync() {
|
||||||
pendingByFolder.current.clear();
|
pendingByFolder.current.clear();
|
||||||
firstEventTime.current.clear();
|
firstEventTime.current.clear();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [electronAPI]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
surfsense_web/hooks/use-platform.ts
Normal file
12
surfsense_web/hooks/use-platform.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { PlatformContext, type PlatformContextValue } from "@/contexts/platform-context";
|
||||||
|
|
||||||
|
export function usePlatform(): Pick<PlatformContextValue, "isDesktop" | "isWeb"> {
|
||||||
|
const { isDesktop, isWeb } = useContext(PlatformContext);
|
||||||
|
return { isDesktop, isWeb };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useElectronAPI(): ElectronAPI | null {
|
||||||
|
const { electronAPI } = useContext(PlatformContext);
|
||||||
|
return electronAPI;
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ const PUBLIC_ROUTE_PREFIXES = [
|
||||||
"/login",
|
"/login",
|
||||||
"/register",
|
"/register",
|
||||||
"/auth",
|
"/auth",
|
||||||
|
"/desktop/login",
|
||||||
"/docs",
|
"/docs",
|
||||||
"/public",
|
"/public",
|
||||||
"/invite",
|
"/invite",
|
||||||
|
|
@ -34,6 +35,11 @@ export function isPublicRoute(pathname: string): boolean {
|
||||||
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLoginPath(): string {
|
||||||
|
if (typeof window !== "undefined" && window.electronAPI) return "/desktop/login";
|
||||||
|
return "/login";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears tokens and optionally redirects to login.
|
* Clears tokens and optionally redirects to login.
|
||||||
* Call this when a 401 response is received.
|
* Call this when a 401 response is received.
|
||||||
|
|
@ -55,7 +61,7 @@ export function handleUnauthorized(): void {
|
||||||
if (!excludedPaths.includes(pathname)) {
|
if (!excludedPaths.includes(pathname)) {
|
||||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||||
}
|
}
|
||||||
window.location.href = "/login";
|
window.location.href = getLoginPath();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,6 +93,7 @@ export function getBearerToken(): string | null {
|
||||||
export function setBearerToken(token: string): void {
|
export function setBearerToken(token: string): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
localStorage.setItem(BEARER_TOKEN_KEY, token);
|
localStorage.setItem(BEARER_TOKEN_KEY, token);
|
||||||
|
syncTokensToElectron();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -111,6 +118,7 @@ export function getRefreshToken(): string | null {
|
||||||
export function setRefreshToken(token: string): void {
|
export function setRefreshToken(token: string): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||||
|
syncTokensToElectron();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -129,6 +137,44 @@ export function clearAllTokens(): void {
|
||||||
clearRefreshToken();
|
clearRefreshToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes the current localStorage tokens into the Electron main process
|
||||||
|
* so that other BrowserWindows (Quick Ask, Autocomplete) can access them.
|
||||||
|
*/
|
||||||
|
function syncTokensToElectron(): void {
|
||||||
|
if (typeof window === "undefined" || !window.electronAPI?.setAuthTokens) return;
|
||||||
|
const bearer = localStorage.getItem(BEARER_TOKEN_KEY) || "";
|
||||||
|
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY) || "";
|
||||||
|
if (bearer) {
|
||||||
|
window.electronAPI.setAuthTokens(bearer, refresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to pull auth tokens from the Electron main process into localStorage.
|
||||||
|
* Useful for popup windows (Quick Ask, Autocomplete) on platforms where
|
||||||
|
* localStorage is not reliably shared across BrowserWindow instances.
|
||||||
|
* Returns true if tokens were found and written to localStorage.
|
||||||
|
*/
|
||||||
|
export async function ensureTokensFromElectron(): Promise<boolean> {
|
||||||
|
if (typeof window === "undefined" || !window.electronAPI?.getAuthTokens) return false;
|
||||||
|
if (getBearerToken()) return true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await window.electronAPI.getAuthTokens();
|
||||||
|
if (tokens?.bearer) {
|
||||||
|
localStorage.setItem(BEARER_TOKEN_KEY, tokens.bearer);
|
||||||
|
if (tokens.refresh) {
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// IPC failure — fall through
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout the current user by revoking the refresh token and clearing localStorage.
|
* Logout the current user by revoking the refresh token and clearing localStorage.
|
||||||
* Returns true if logout was successful (or tokens were cleared), false otherwise.
|
* Returns true if logout was successful (or tokens were cleared), false otherwise.
|
||||||
|
|
@ -181,13 +227,12 @@ export function redirectToLogin(): void {
|
||||||
const currentPath = window.location.pathname + window.location.search + window.location.hash;
|
const currentPath = window.location.pathname + window.location.search + window.location.hash;
|
||||||
|
|
||||||
// Don't save auth-related paths or home page
|
// Don't save auth-related paths or home page
|
||||||
const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register"];
|
const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register", "/desktop/login"];
|
||||||
if (!excludedPaths.includes(window.location.pathname)) {
|
if (!excludedPaths.includes(window.location.pathname)) {
|
||||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to login page
|
window.location.href = getLoginPath();
|
||||||
window.location.href = "/login";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
8
surfsense_web/types/window.d.ts
vendored
8
surfsense_web/types/window.d.ts
vendored
|
|
@ -85,6 +85,14 @@ interface ElectronAPI {
|
||||||
// Browse files/folders via native dialogs
|
// Browse files/folders via native dialogs
|
||||||
browseFiles: () => Promise<string[] | null>;
|
browseFiles: () => Promise<string[] | null>;
|
||||||
readLocalFiles: (paths: string[]) => Promise<LocalFileData[]>;
|
readLocalFiles: (paths: string[]) => Promise<LocalFileData[]>;
|
||||||
|
// Auth token sync across windows
|
||||||
|
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
|
||||||
|
setAuthTokens: (bearer: string, refresh: string) => Promise<void>;
|
||||||
|
// Keyboard shortcut configuration
|
||||||
|
getShortcuts: () => Promise<{ quickAsk: string; autocomplete: string }>;
|
||||||
|
setShortcuts: (
|
||||||
|
config: Partial<{ quickAsk: string; autocomplete: string }>
|
||||||
|
) => Promise<{ quickAsk: string; autocomplete: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue