Merge upstream/dev into feature/mcp-migration

This commit is contained in:
CREDO23 2026-04-22 19:53:26 +02:00
commit 4915675f45
54 changed files with 2050 additions and 359 deletions

View file

@ -22,6 +22,7 @@ on:
permissions: permissions:
contents: write contents: write
id-token: write
jobs: jobs:
build: build:
@ -58,6 +59,22 @@ jobs:
fi fi
echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT"
- name: Detect Windows signing eligibility
id: sign
shell: bash
run: |
# Sign Windows builds only on production v* tags (not beta-v*, not workflow_dispatch).
# This matches the single OIDC federated credential configured in Entra ID.
if [ "${{ matrix.os }}" = "windows-latest" ] \
&& [ "${{ github.event_name }}" = "push" ] \
&& [[ "$GITHUB_REF" == refs/tags/v* ]]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "Windows signing: ENABLED (v* tag on windows-latest)"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "Windows signing: skipped"
fi
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v5 uses: pnpm/action-setup@v5
@ -98,7 +115,31 @@ jobs:
- name: Package & Publish - name: Package & Publish
shell: bash shell: bash
run: pnpm exec electron-builder ${{ matrix.platform }} --config electron-builder.yml --publish ${{ inputs.publish || 'always' }} -c.extraMetadata.version=${{ steps.version.outputs.VERSION }} run: |
CMD=(pnpm exec electron-builder ${{ matrix.platform }} \
--config electron-builder.yml \
--publish "${{ inputs.publish || 'always' }}" \
-c.extraMetadata.version="${{ steps.version.outputs.VERSION }}")
if [ "${{ steps.sign.outputs.enabled }}" = "true" ]; then
CMD+=(-c.win.azureSignOptions.publisherName="$WINDOWS_PUBLISHER_NAME")
CMD+=(-c.win.azureSignOptions.endpoint="$AZURE_CODESIGN_ENDPOINT")
CMD+=(-c.win.azureSignOptions.codeSigningAccountName="$AZURE_CODESIGN_ACCOUNT")
CMD+=(-c.win.azureSignOptions.certificateProfileName="$AZURE_CODESIGN_PROFILE")
fi
"${CMD[@]}"
working-directory: surfsense_desktop working-directory: surfsense_desktop
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WINDOWS_PUBLISHER_NAME: ${{ vars.WINDOWS_PUBLISHER_NAME }}
AZURE_CODESIGN_ENDPOINT: ${{ vars.AZURE_CODESIGN_ENDPOINT }}
AZURE_CODESIGN_ACCOUNT: ${{ vars.AZURE_CODESIGN_ACCOUNT }}
AZURE_CODESIGN_PROFILE: ${{ vars.AZURE_CODESIGN_PROFILE }}
# Service principal credentials for Azure.Identity EnvironmentCredential used by the
# TrustedSigning PowerShell module. Only populated when signing is enabled.
# electron-builder 26 does not yet support OIDC federated tokens for Azure signing,
# so we fall back to client-secret auth. Rotate AZURE_CLIENT_SECRET before expiry.
AZURE_TENANT_ID: ${{ steps.sign.outputs.enabled == 'true' && secrets.AZURE_TENANT_ID || '' }}
AZURE_CLIENT_ID: ${{ steps.sign.outputs.enabled == 'true' && secrets.AZURE_CLIENT_ID || '' }}
AZURE_CLIENT_SECRET: ${{ steps.sign.outputs.enabled == 'true' && secrets.AZURE_CLIENT_SECRET || '' }}

View file

@ -1 +1 @@
0.0.16 0.0.19

View file

@ -71,6 +71,7 @@ EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
# BACKEND_URL=https://api.yourdomain.com # BACKEND_URL=https://api.yourdomain.com
# NEXT_PUBLIC_FASTAPI_BACKEND_URL=https://api.yourdomain.com # NEXT_PUBLIC_FASTAPI_BACKEND_URL=https://api.yourdomain.com
# NEXT_PUBLIC_ZERO_CACHE_URL=https://zero.yourdomain.com # NEXT_PUBLIC_ZERO_CACHE_URL=https://zero.yourdomain.com
# FASTAPI_BACKEND_INTERNAL_URL=http://backend:8000
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Zero-cache (real-time sync) # Zero-cache (real-time sync)

View file

@ -77,6 +77,8 @@ services:
- shared_temp:/shared_tmp - shared_temp:/shared_tmp
env_file: env_file:
- ../surfsense_backend/.env - ../surfsense_backend/.env
extra_hosts:
- "host.docker.internal:host-gateway"
environment: environment:
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}} - DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
- CELERY_BROKER_URL=${REDIS_URL:-redis://redis:6379/0} - CELERY_BROKER_URL=${REDIS_URL:-redis://redis:6379/0}
@ -118,6 +120,8 @@ services:
- shared_temp:/shared_tmp - shared_temp:/shared_tmp
env_file: env_file:
- ../surfsense_backend/.env - ../surfsense_backend/.env
extra_hosts:
- "host.docker.internal:host-gateway"
environment: environment:
- DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}} - DATABASE_URL=${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
- CELERY_BROKER_URL=${REDIS_URL:-redis://redis:6379/0} - CELERY_BROKER_URL=${REDIS_URL:-redis://redis:6379/0}

View file

@ -60,6 +60,8 @@ services:
- shared_temp:/shared_tmp - shared_temp:/shared_tmp
env_file: env_file:
- .env - .env
extra_hosts:
- "host.docker.internal:host-gateway"
environment: environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}} DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
CELERY_BROKER_URL: ${REDIS_URL:-redis://redis:6379/0} CELERY_BROKER_URL: ${REDIS_URL:-redis://redis:6379/0}
@ -100,6 +102,8 @@ services:
- shared_temp:/shared_tmp - shared_temp:/shared_tmp
env_file: env_file:
- .env - .env
extra_hosts:
- "host.docker.internal:host-gateway"
environment: environment:
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}} DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://${DB_USER:-surfsense}:${DB_PASSWORD:-surfsense}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-surfsense}}
CELERY_BROKER_URL: ${REDIS_URL:-redis://redis:6379/0} CELERY_BROKER_URL: ${REDIS_URL:-redis://redis:6379/0}
@ -198,6 +202,7 @@ services:
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL} NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING} NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted} NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
FASTAPI_BACKEND_INTERNAL_URL: ${FASTAPI_BACKEND_INTERNAL_URL:-http://backend:8000}
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=true"
depends_on: depends_on:

View file

@ -24,7 +24,6 @@ from deepagents.backends import StateBackend
from deepagents.graph import BASE_AGENT_PROMPT from deepagents.graph import BASE_AGENT_PROMPT
from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware from deepagents.middleware.patch_tool_calls import PatchToolCallsMiddleware
from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT
from deepagents.middleware.summarization import create_summarization_middleware
from langchain.agents import create_agent from langchain.agents import create_agent
from langchain.agents.middleware import TodoListMiddleware from langchain.agents.middleware import TodoListMiddleware
from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware
@ -41,6 +40,9 @@ from app.agents.new_chat.middleware import (
MemoryInjectionMiddleware, MemoryInjectionMiddleware,
SurfSenseFilesystemMiddleware, SurfSenseFilesystemMiddleware,
) )
from app.agents.new_chat.middleware.safe_summarization import (
create_safe_summarization_middleware,
)
from app.agents.new_chat.system_prompt import ( from app.agents.new_chat.system_prompt import (
build_configurable_system_prompt, build_configurable_system_prompt,
build_surfsense_system_prompt, build_surfsense_system_prompt,
@ -347,7 +349,7 @@ async def create_surfsense_deep_agent(
created_by_id=user_id, created_by_id=user_id,
thread_id=thread_id, thread_id=thread_id,
), ),
create_summarization_middleware(llm, StateBackend), create_safe_summarization_middleware(llm, StateBackend),
PatchToolCallsMiddleware(), PatchToolCallsMiddleware(),
AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"), AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"),
] ]
@ -377,7 +379,7 @@ async def create_surfsense_deep_agent(
thread_id=thread_id, thread_id=thread_id,
), ),
SubAgentMiddleware(backend=StateBackend, subagents=[general_purpose_spec]), SubAgentMiddleware(backend=StateBackend, subagents=[general_purpose_spec]),
create_summarization_middleware(llm, StateBackend), create_safe_summarization_middleware(llm, StateBackend),
PatchToolCallsMiddleware(), PatchToolCallsMiddleware(),
DedupHITLToolCallsMiddleware(agent_tools=tools), DedupHITLToolCallsMiddleware(agent_tools=tools),
AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"), AnthropicPromptCachingMiddleware(unsupported_model_behavior="ignore"),

View file

@ -0,0 +1,123 @@
"""Safe wrapper around deepagents' SummarizationMiddleware.
Upstream issue
--------------
`deepagents.middleware.summarization.SummarizationMiddleware._aoffload_to_backend`
(and its sync counterpart) call
``get_buffer_string(filtered_messages)`` before writing the evicted history
to the backend file. In recent ``langchain-core`` versions, ``get_buffer_string``
accesses ``m.text`` which iterates ``self.content`` this raises
``TypeError: 'NoneType' object is not iterable`` whenever an ``AIMessage``
has ``content=None`` (common when a model returns *only* tool_calls, seen
frequently with Azure OpenAI ``gpt-5.x`` responses streamed through
LiteLLM).
The exception aborts the whole agent turn, so the user just sees "Error during
chat" with no assistant response.
Fix
---
We subclass ``SummarizationMiddleware`` and override
``_filter_summary_messages`` the only call site that feeds messages into
``get_buffer_string`` to return *copies* of messages whose ``content`` is
``None`` with ``content=""``. The originals flowing through the rest of the
agent state are untouched.
We also expose a drop-in ``create_safe_summarization_middleware`` factory
that mirrors ``deepagents.middleware.summarization.create_summarization_middleware``
but instantiates our safe subclass.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from deepagents.middleware.summarization import (
SummarizationMiddleware,
compute_summarization_defaults,
)
if TYPE_CHECKING:
from deepagents.backends.protocol import BACKEND_TYPES
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AnyMessage
logger = logging.getLogger(__name__)
def _sanitize_message_content(msg: AnyMessage) -> AnyMessage:
"""Return ``msg`` with ``content`` coerced to a non-``None`` value.
``get_buffer_string`` reads ``m.text`` which iterates ``self.content``;
when a provider streams back an ``AIMessage`` with only tool_calls and
no text, ``content`` can be ``None`` and the iteration explodes. We
replace ``None`` with an empty string so downstream consumers that only
care about text see an empty body.
The original message is left untouched we return a copy via
pydantic's ``model_copy`` when available, otherwise we fall back to
re-setting the attribute on a shallow copy.
"""
if getattr(msg, "content", "not-missing") is not None:
return msg
try:
return msg.model_copy(update={"content": ""})
except AttributeError:
import copy
new_msg = copy.copy(msg)
try:
new_msg.content = ""
except Exception: # pragma: no cover - defensive
logger.debug(
"Could not sanitize content=None on message of type %s",
type(msg).__name__,
)
return msg
return new_msg
class SafeSummarizationMiddleware(SummarizationMiddleware):
"""`SummarizationMiddleware` that tolerates messages with ``content=None``.
Only ``_filter_summary_messages`` is overridden this is the single
helper invoked by both the sync and async offload paths immediately
before ``get_buffer_string``. Normalising here means we get coverage
for both without having to copy the (long, rapidly-changing) offload
implementations from upstream.
"""
def _filter_summary_messages(self, messages: list[AnyMessage]) -> list[AnyMessage]:
filtered = super()._filter_summary_messages(messages)
return [_sanitize_message_content(m) for m in filtered]
def create_safe_summarization_middleware(
model: BaseChatModel,
backend: BACKEND_TYPES,
) -> SafeSummarizationMiddleware:
"""Drop-in replacement for ``create_summarization_middleware``.
Mirrors the defaults computed by ``deepagents`` but returns our
``SafeSummarizationMiddleware`` subclass so the
``content=None`` crash in ``get_buffer_string`` is avoided.
"""
defaults = compute_summarization_defaults(model)
return SafeSummarizationMiddleware(
model=model,
backend=backend,
trigger=defaults["trigger"],
keep=defaults["keep"],
trim_tokens_to_summarize=None,
truncate_args_settings=defaults["truncate_args_settings"],
)
__all__ = [
"SafeSummarizationMiddleware",
"create_safe_summarization_middleware",
]

View file

@ -114,8 +114,19 @@ def _surfsense_error_handler(request: Request, exc: SurfSenseError) -> JSONRespo
def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: def _http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
"""Wrap FastAPI/Starlette HTTPExceptions into the standard envelope.""" """Wrap FastAPI/Starlette HTTPExceptions into the standard envelope.
5xx sanitization policy:
- 500 responses are sanitized (replaced with ``GENERIC_5XX_MESSAGE``) because
they usually wrap raw internal errors and may leak sensitive info.
- Other 5xx statuses (501, 502, 503, 504, ...) are raised explicitly by
route code to communicate a specific, user-safe operational state
(e.g. 503 "Page purchases are temporarily unavailable."). Those details
are preserved so the frontend can render them, but the error is still
logged server-side.
"""
rid = _get_request_id(request) rid = _get_request_id(request)
should_sanitize = exc.status_code == 500
# Structured dict details (e.g. {"code": "CAPTCHA_REQUIRED", "message": "..."}) # Structured dict details (e.g. {"code": "CAPTCHA_REQUIRED", "message": "..."})
# are preserved so the frontend can parse them. # are preserved so the frontend can parse them.
@ -130,6 +141,7 @@ def _http_exception_handler(request: Request, exc: HTTPException) -> JSONRespons
exc.status_code, exc.status_code,
message, message,
) )
if should_sanitize:
message = GENERIC_5XX_MESSAGE message = GENERIC_5XX_MESSAGE
err_code = "INTERNAL_ERROR" err_code = "INTERNAL_ERROR"
body = { body = {
@ -158,6 +170,7 @@ def _http_exception_handler(request: Request, exc: HTTPException) -> JSONRespons
exc.status_code, exc.status_code,
detail, detail,
) )
if should_sanitize:
detail = GENERIC_5XX_MESSAGE detail = GENERIC_5XX_MESSAGE
code = _status_to_code(exc.status_code, detail) code = _status_to_code(exc.status_code, detail)
return _build_error_response(exc.status_code, detail, code=code, request_id=rid) return _build_error_response(exc.status_code, detail, code=code, request_id=rid)

View file

@ -133,6 +133,44 @@ PROVIDER_MAP = {
} }
# Default ``api_base`` per LiteLLM provider prefix. Used as a safety net when
# a global LLM config does *not* specify ``api_base``: without this, LiteLLM
# happily picks up provider-agnostic env vars (e.g. ``AZURE_API_BASE``,
# ``OPENAI_API_BASE``) and routes, say, an ``openrouter/anthropic/claude-3-haiku``
# request to an Azure endpoint, which then 404s with ``Resource not found``.
# Only providers with a well-known, stable public base URL are listed here —
# self-hosted / BYO-endpoint providers (ollama, custom, bedrock, vertex_ai,
# huggingface, databricks, cloudflare, replicate) are intentionally omitted
# so their existing config-driven behaviour is preserved.
PROVIDER_DEFAULT_API_BASE = {
"openrouter": "https://openrouter.ai/api/v1",
"groq": "https://api.groq.com/openai/v1",
"mistral": "https://api.mistral.ai/v1",
"perplexity": "https://api.perplexity.ai",
"xai": "https://api.x.ai/v1",
"cerebras": "https://api.cerebras.ai/v1",
"deepinfra": "https://api.deepinfra.com/v1/openai",
"fireworks_ai": "https://api.fireworks.ai/inference/v1",
"together_ai": "https://api.together.xyz/v1",
"anyscale": "https://api.endpoints.anyscale.com/v1",
"cometapi": "https://api.cometapi.com/v1",
"sambanova": "https://api.sambanova.ai/v1",
}
# Canonical provider → base URL when a config uses a generic ``openai``-style
# prefix but the ``provider`` field tells us which API it really is
# (e.g. DeepSeek/Alibaba/Moonshot/Zhipu/MiniMax all use ``openai`` compat but
# each has its own base URL).
PROVIDER_KEY_DEFAULT_API_BASE = {
"DEEPSEEK": "https://api.deepseek.com/v1",
"ALIBABA_QWEN": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
"MOONSHOT": "https://api.moonshot.ai/v1",
"ZHIPU": "https://open.bigmodel.cn/api/paas/v4",
"MINIMAX": "https://api.minimax.io/v1",
}
class LLMRouterService: class LLMRouterService:
""" """
Singleton service for managing LiteLLM Router. Singleton service for managing LiteLLM Router.
@ -224,6 +262,16 @@ class LLMRouterService:
# hits ContextWindowExceededError. # hits ContextWindowExceededError.
full_model_list, ctx_fallbacks = cls._build_context_fallback_groups(model_list) full_model_list, ctx_fallbacks = cls._build_context_fallback_groups(model_list)
# Build a general-purpose fallback list so NotFound/timeout/rate-limit
# style failures on one deployment don't bubble up as hard errors —
# the router retries with a sibling deployment in ``auto-large``.
# ``auto-large`` is the large-context subset of ``auto``; if it is
# empty we fall back to ``auto`` itself so the router at least picks a
# different deployment in the same group.
fallbacks: list[dict[str, list[str]]] | None = None
if ctx_fallbacks:
fallbacks = [{"auto": ["auto-large"]}]
try: try:
router_kwargs: dict[str, Any] = { router_kwargs: dict[str, Any] = {
"model_list": full_model_list, "model_list": full_model_list,
@ -237,15 +285,18 @@ class LLMRouterService:
} }
if ctx_fallbacks: if ctx_fallbacks:
router_kwargs["context_window_fallbacks"] = ctx_fallbacks router_kwargs["context_window_fallbacks"] = ctx_fallbacks
if fallbacks:
router_kwargs["fallbacks"] = fallbacks
instance._router = Router(**router_kwargs) instance._router = Router(**router_kwargs)
instance._initialized = True instance._initialized = True
logger.info( logger.info(
"LLM Router initialized with %d deployments, " "LLM Router initialized with %d deployments, "
"strategy: %s, context_window_fallbacks: %s", "strategy: %s, context_window_fallbacks: %s, fallbacks: %s",
len(model_list), len(model_list),
final_settings.get("routing_strategy"), final_settings.get("routing_strategy"),
ctx_fallbacks or "none", ctx_fallbacks or "none",
fallbacks or "none",
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to initialize LLM Router: {e}") logger.error(f"Failed to initialize LLM Router: {e}")
@ -348,10 +399,11 @@ class LLMRouterService:
return None return None
# Build model string # Build model string
provider = config.get("provider", "").upper()
if config.get("custom_provider"): if config.get("custom_provider"):
model_string = f"{config['custom_provider']}/{config['model_name']}" provider_prefix = config["custom_provider"]
model_string = f"{provider_prefix}/{config['model_name']}"
else: else:
provider = config.get("provider", "").upper()
provider_prefix = PROVIDER_MAP.get(provider, provider.lower()) provider_prefix = PROVIDER_MAP.get(provider, provider.lower())
model_string = f"{provider_prefix}/{config['model_name']}" model_string = f"{provider_prefix}/{config['model_name']}"
@ -361,9 +413,19 @@ class LLMRouterService:
"api_key": config.get("api_key"), "api_key": config.get("api_key"),
} }
# Add optional api_base # Resolve ``api_base``. Config value wins; otherwise apply a
if config.get("api_base"): # provider-aware default so the deployment does not silently
litellm_params["api_base"] = config["api_base"] # inherit unrelated env vars (e.g. ``AZURE_API_BASE``) and route
# requests to the wrong endpoint. See ``PROVIDER_DEFAULT_API_BASE``
# docstring for the motivating bug (OpenRouter models 404-ing
# against an Azure endpoint).
api_base = config.get("api_base")
if not api_base:
api_base = PROVIDER_KEY_DEFAULT_API_BASE.get(provider)
if not api_base:
api_base = PROVIDER_DEFAULT_API_BASE.get(provider_prefix)
if api_base:
litellm_params["api_base"] = api_base
# Add any additional litellm parameters # Add any additional litellm parameters
if config.get("litellm_params"): if config.get("litellm_params"):

View file

@ -1,3 +1,4 @@
import asyncio
import logging import logging
import litellm import litellm
@ -32,6 +33,39 @@ litellm.callbacks = [token_tracker]
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Providers that require an interactive OAuth / device-flow login before
# issuing any completion. LiteLLM implements these with blocking sync polling
# (requests + time.sleep), which would freeze the FastAPI event loop if
# invoked from validation. They are never usable from a headless backend,
# so we reject them at the edge.
_INTERACTIVE_AUTH_PROVIDERS: frozenset[str] = frozenset(
{
"github_copilot",
"github-copilot",
"githubcopilot",
"copilot",
}
)
# Hard upper bound for a single validation call. Must exceed the ChatLiteLLM
# request timeout (30s) by a small margin so a well-behaved provider never
# trips the watchdog, while any pathological/blocking provider is killed.
_VALIDATION_TIMEOUT_SECONDS: float = 35.0
def _is_interactive_auth_provider(
provider: str | None, custom_provider: str | None
) -> bool:
"""Return True if the given provider triggers interactive OAuth in LiteLLM."""
for raw in (custom_provider, provider):
if not raw:
continue
normalized = raw.strip().lower().replace(" ", "_")
if normalized in _INTERACTIVE_AUTH_PROVIDERS:
return True
return False
class LLMRole: class LLMRole:
AGENT = "agent" # For agent/chat operations AGENT = "agent" # For agent/chat operations
DOCUMENT_SUMMARY = "document_summary" # For document summarization DOCUMENT_SUMMARY = "document_summary" # For document summarization
@ -93,6 +127,25 @@ async def validate_llm_config(
- is_valid: True if config works, False otherwise - is_valid: True if config works, False otherwise
- error_message: Empty string if valid, error description if invalid - error_message: Empty string if valid, error description if invalid
""" """
# Reject providers that require interactive OAuth/device-flow auth.
# LiteLLM's github_copilot provider (and similar) uses a blocking sync
# Authenticator that polls GitHub for up to several minutes and prints a
# device code to stdout. Running it on the FastAPI event loop will freeze
# the entire backend, so we refuse them up front.
if _is_interactive_auth_provider(provider, custom_provider):
msg = (
"Provider requires interactive OAuth/device-flow authentication "
"(e.g. github_copilot) and cannot be used in a hosted backend. "
"Please choose a provider that authenticates via API key."
)
logger.warning(
"Rejected LLM config validation for interactive-auth provider "
"(provider=%r, custom_provider=%r)",
provider,
custom_provider,
)
return False, msg
try: try:
# Build the model string for litellm # Build the model string for litellm
if custom_provider: if custom_provider:
@ -153,9 +206,30 @@ async def validate_llm_config(
llm = SanitizedChatLiteLLM(**litellm_kwargs) llm = SanitizedChatLiteLLM(**litellm_kwargs)
# Make a simple test call # Run the test call in a worker thread with a hard timeout. Some
# LiteLLM providers have synchronous blocking code paths (e.g. OAuth
# authenticators that call time.sleep and requests.post) that would
# otherwise freeze the asyncio event loop. Offloading to a thread and
# bounding the wait keeps the server responsive even if a provider
# misbehaves.
test_message = HumanMessage(content="Hello") test_message = HumanMessage(content="Hello")
response = await llm.ainvoke([test_message]) try:
response = await asyncio.wait_for(
asyncio.to_thread(llm.invoke, [test_message]),
timeout=_VALIDATION_TIMEOUT_SECONDS,
)
except TimeoutError:
logger.warning(
"LLM config validation timed out after %ss for model: %s",
_VALIDATION_TIMEOUT_SECONDS,
model_string,
)
return (
False,
f"Validation timed out after {int(_VALIDATION_TIMEOUT_SECONDS)}s. "
"The provider is unreachable or requires interactive "
"authentication that is not supported by the backend.",
)
# If we got here without exception, the config is valid # If we got here without exception, the config is valid
if response and response.content: if response and response.content:

View file

@ -1,6 +1,6 @@
[project] [project]
name = "surf-new-backend" name = "surf-new-backend"
version = "0.0.16" version = "0.0.19"
description = "SurfSense Backend" description = "SurfSense Backend"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
@ -74,7 +74,7 @@ dependencies = [
"deepagents>=0.4.12", "deepagents>=0.4.12",
"stripe>=15.0.0", "stripe>=15.0.0",
"azure-ai-documentintelligence>=1.0.2", "azure-ai-documentintelligence>=1.0.2",
"litellm>=1.83.0", "litellm>=1.83.4",
"langchain-litellm>=0.6.4", "langchain-litellm>=0.6.4",
] ]

View file

@ -70,6 +70,20 @@ def _make_test_app():
async def raise_http_500(): async def raise_http_500():
raise HTTPException(status_code=500, detail="secret db password leaked") raise HTTPException(status_code=500, detail="secret db password leaked")
@app.get("/http-503")
async def raise_http_503():
raise HTTPException(
status_code=503,
detail="Page purchases are temporarily unavailable.",
)
@app.get("/http-502")
async def raise_http_502():
raise HTTPException(
status_code=502,
detail="Unable to create Stripe checkout session.",
)
@app.get("/surfsense-connector") @app.get("/surfsense-connector")
async def raise_connector(): async def raise_connector():
raise ConnectorError("GitHub API returned 401") raise ConnectorError("GitHub API returned 401")
@ -184,6 +198,18 @@ class TestHTTPExceptionHandler:
assert body["error"]["message"] == GENERIC_5XX_MESSAGE assert body["error"]["message"] == GENERIC_5XX_MESSAGE
assert body["error"]["code"] == "INTERNAL_ERROR" assert body["error"]["code"] == "INTERNAL_ERROR"
def test_503_preserves_detail(self, client):
# Intentional 503s (e.g. feature flag off) must surface the developer
# message so the frontend can render actionable copy.
body = _assert_envelope(client.get("/http-503"), 503)
assert body["error"]["message"] == "Page purchases are temporarily unavailable."
assert body["error"]["message"] != GENERIC_5XX_MESSAGE
def test_502_preserves_detail(self, client):
body = _assert_envelope(client.get("/http-502"), 502)
assert body["error"]["message"] == "Unable to create Stripe checkout session."
assert body["error"]["message"] != GENERIC_5XX_MESSAGE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SurfSenseError hierarchy # SurfSenseError hierarchy

View file

@ -7947,7 +7947,7 @@ wheels = [
[[package]] [[package]]
name = "surf-new-backend" name = "surf-new-backend"
version = "0.0.16" version = "0.0.19"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
@ -8070,7 +8070,7 @@ requires-dist = [
{ 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.83.0" }, { name = "litellm", specifier = ">=1.83.4" },
{ 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" },

View file

@ -1,7 +1,7 @@
{ {
"name": "surfsense_browser_extension", "name": "surfsense_browser_extension",
"displayName": "Surfsense Browser Extension", "displayName": "Surfsense Browser Extension",
"version": "0.0.16", "version": "0.0.19",
"description": "Extension to collect Browsing History for SurfSense.", "description": "Extension to collect Browsing History for SurfSense.",
"author": "https://github.com/MODSetter", "author": "https://github.com/MODSetter",
"engines": { "engines": {

View file

@ -1,6 +1,6 @@
{ {
"name": "surfsense-desktop", "name": "surfsense-desktop",
"version": "0.0.16", "version": "0.0.19",
"description": "SurfSense Desktop App", "description": "SurfSense Desktop App",
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "scripts": {

View file

@ -43,4 +43,12 @@ export const IPC_CHANNELS = {
// Active search space // Active search space
GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active', GET_ACTIVE_SEARCH_SPACE: 'search-space:get-active',
SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active', SET_ACTIVE_SEARCH_SPACE: 'search-space:set-active',
// Launch on system startup
GET_AUTO_LAUNCH: 'auto-launch:get',
SET_AUTO_LAUNCH: 'auto-launch:set',
// Analytics (PostHog) bridge: renderer <-> main
ANALYTICS_IDENTIFY: 'analytics:identify',
ANALYTICS_RESET: 'analytics:reset',
ANALYTICS_CAPTURE: 'analytics:capture',
ANALYTICS_GET_CONTEXT: 'analytics:get-context',
} as const; } as const;

View file

@ -24,10 +24,18 @@ import {
type WatchedFolderConfig, type WatchedFolderConfig,
} from '../modules/folder-watcher'; } from '../modules/folder-watcher';
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts'; import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
import { getAutoLaunchState, setAutoLaunch } from '../modules/auto-launch';
import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space'; import { getActiveSearchSpaceId, setActiveSearchSpaceId } from '../modules/active-search-space';
import { reregisterQuickAsk } from '../modules/quick-ask'; import { reregisterQuickAsk } from '../modules/quick-ask';
import { reregisterAutocomplete } from '../modules/autocomplete'; import { reregisterAutocomplete } from '../modules/autocomplete';
import { reregisterGeneralAssist } from '../modules/tray'; import { reregisterGeneralAssist } from '../modules/tray';
import {
getDistinctId,
getMachineId,
identifyUser as analyticsIdentify,
resetUser as analyticsReset,
trackEvent,
} from '../modules/analytics';
let authTokens: { bearer: string; refresh: string } | null = null; let authTokens: { bearer: string; refresh: string } | null = null;
@ -120,6 +128,21 @@ export function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts()); ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts());
ipcMain.handle(IPC_CHANNELS.GET_AUTO_LAUNCH, () => getAutoLaunchState());
ipcMain.handle(
IPC_CHANNELS.SET_AUTO_LAUNCH,
async (_event, payload: { enabled: boolean; openAsHidden?: boolean }) => {
const next = await setAutoLaunch(payload.enabled, payload.openAsHidden);
trackEvent('desktop_auto_launch_toggled', {
enabled: next.enabled,
open_as_hidden: next.openAsHidden,
supported: next.supported,
});
return next;
},
);
ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE, () => getActiveSearchSpaceId()); ipcMain.handle(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE, () => getActiveSearchSpaceId());
ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, (_event, id: string) => ipcMain.handle(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, (_event, id: string) =>
@ -131,6 +154,41 @@ export function registerIpcHandlers(): void {
if (config.generalAssist) await reregisterGeneralAssist(); if (config.generalAssist) await reregisterGeneralAssist();
if (config.quickAsk) await reregisterQuickAsk(); if (config.quickAsk) await reregisterQuickAsk();
if (config.autocomplete) await reregisterAutocomplete(); if (config.autocomplete) await reregisterAutocomplete();
trackEvent('desktop_shortcut_updated', {
keys: Object.keys(config),
});
return updated; return updated;
}); });
// Analytics bridge — the renderer (web UI) hands the logged-in user down
// to the main process so desktop-only events are attributed to the same
// PostHog person, not just an anonymous machine ID.
ipcMain.handle(
IPC_CHANNELS.ANALYTICS_IDENTIFY,
(_event, payload: { userId: string; properties?: Record<string, unknown> }) => {
if (!payload?.userId) return;
analyticsIdentify(String(payload.userId), payload.properties);
}
);
ipcMain.handle(IPC_CHANNELS.ANALYTICS_RESET, () => {
analyticsReset();
});
ipcMain.handle(
IPC_CHANNELS.ANALYTICS_CAPTURE,
(_event, payload: { event: string; properties?: Record<string, unknown> }) => {
if (!payload?.event) return;
trackEvent(payload.event, payload.properties);
}
);
ipcMain.handle(IPC_CHANNELS.ANALYTICS_GET_CONTEXT, () => {
return {
distinctId: getDistinctId(),
machineId: getMachineId(),
appVersion: app.getVersion(),
platform: process.platform,
};
});
} }

View file

@ -1,10 +1,9 @@
import { app, BrowserWindow } from 'electron'; import { app } from 'electron';
let isQuitting = false;
import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors'; import { registerGlobalErrorHandlers, showErrorDialog } from './modules/errors';
import { startNextServer } from './modules/server'; import { startNextServer } from './modules/server';
import { createMainWindow, getMainWindow } from './modules/window'; import { createMainWindow, getMainWindow, markQuitting } from './modules/window';
import { setupDeepLinks, handlePendingDeepLink } from './modules/deep-links'; import { setupDeepLinks, handlePendingDeepLink, hasPendingDeepLink } from './modules/deep-links';
import { setupAutoUpdater } from './modules/auto-updater'; import { setupAutoUpdater } from './modules/auto-updater';
import { setupMenu } from './modules/menu'; import { setupMenu } from './modules/menu';
import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask'; import { registerQuickAsk, unregisterQuickAsk } from './modules/quick-ask';
@ -13,6 +12,12 @@ import { registerFolderWatcher, unregisterFolderWatcher } from './modules/folder
import { registerIpcHandlers } from './ipc/handlers'; import { registerIpcHandlers } from './ipc/handlers';
import { createTray, destroyTray } from './modules/tray'; import { createTray, destroyTray } from './modules/tray';
import { initAnalytics, shutdownAnalytics, trackEvent } from './modules/analytics'; import { initAnalytics, shutdownAnalytics, trackEvent } from './modules/analytics';
import {
applyAutoLaunchDefaults,
shouldStartHidden,
syncAutoLaunchOnStartup,
wasLaunchedAtLogin,
} from './modules/auto-launch';
registerGlobalErrorHandlers(); registerGlobalErrorHandlers();
@ -24,7 +29,12 @@ registerIpcHandlers();
app.whenReady().then(async () => { app.whenReady().then(async () => {
initAnalytics(); initAnalytics();
trackEvent('desktop_app_launched'); const launchedAtLogin = wasLaunchedAtLogin();
const startedHidden = shouldStartHidden();
trackEvent('desktop_app_launched', {
launched_at_login: launchedAtLogin,
started_hidden: startedHidden,
});
setupMenu(); setupMenu();
try { try {
await startNextServer(); await startNextServer();
@ -35,16 +45,19 @@ app.whenReady().then(async () => {
} }
await createTray(); await createTray();
const defaultsApplied = await applyAutoLaunchDefaults();
if (defaultsApplied) {
trackEvent('desktop_auto_launch_defaulted_on');
}
await syncAutoLaunchOnStartup();
const win = createMainWindow('/dashboard'); // When started by the OS at login we stay quietly in the tray. The window
// is created lazily on first user interaction (tray click / activate).
// Minimize to tray instead of closing the app // Exception: if a deep link is queued, the user explicitly asked to land
win.on('close', (e) => { // in the app — don't swallow it.
if (!isQuitting) { if (!startedHidden || hasPendingDeepLink()) {
e.preventDefault(); createMainWindow('/dashboard');
win.hide(); }
}
});
await registerQuickAsk(); await registerQuickAsk();
await registerAutocomplete(); await registerAutocomplete();
@ -55,6 +68,7 @@ app.whenReady().then(async () => {
app.on('activate', () => { app.on('activate', () => {
const mw = getMainWindow(); const mw = getMainWindow();
trackEvent('desktop_app_activated');
if (!mw || mw.isDestroyed()) { if (!mw || mw.isDestroyed()) {
createMainWindow('/dashboard'); createMainWindow('/dashboard');
} else { } else {
@ -70,7 +84,8 @@ app.on('window-all-closed', () => {
}); });
app.on('before-quit', () => { app.on('before-quit', () => {
isQuitting = true; markQuitting();
trackEvent('desktop_app_quit');
}); });
let didCleanup = false; let didCleanup = false;

View file

@ -3,14 +3,27 @@ import { machineIdSync } from 'node-machine-id';
import { app } from 'electron'; import { app } from 'electron';
let client: PostHog | null = null; let client: PostHog | null = null;
let distinctId = ''; let machineId = '';
let currentDistinctId = '';
let identifiedUserId: string | null = null;
function baseProperties(): Record<string, unknown> {
return {
platform: 'desktop',
app_version: app.getVersion(),
os: process.platform,
arch: process.arch,
machine_id: machineId,
};
}
export function initAnalytics(): void { export function initAnalytics(): void {
const key = process.env.POSTHOG_KEY; const key = process.env.POSTHOG_KEY;
if (!key) return; if (!key) return;
try { try {
distinctId = machineIdSync(true); machineId = machineIdSync(true);
currentDistinctId = machineId;
} catch { } catch {
return; return;
} }
@ -22,17 +35,92 @@ export function initAnalytics(): void {
}); });
} }
export function trackEvent(event: string, properties?: Record<string, unknown>): void { export function getMachineId(): string {
return machineId;
}
export function getDistinctId(): string {
return currentDistinctId;
}
/**
* Identify the current logged-in user in PostHog so main-process desktop
* events (and linked anonymous machine events) are attributed to that person.
*
* Idempotent: calling identify repeatedly with the same userId is a no-op.
*/
export function identifyUser(
userId: string,
properties?: Record<string, unknown>
): void {
if (!client || !userId) return;
if (identifiedUserId === userId) {
// Already identified — only refresh person properties
try {
client.identify({
distinctId: userId,
properties: {
...baseProperties(),
$set: {
...(properties || {}),
platform: 'desktop',
last_seen_at: new Date().toISOString(),
},
},
});
} catch {
// ignore
}
return;
}
try {
// Link the anonymous machine distinct ID to the authenticated user
client.identify({
distinctId: userId,
properties: {
...baseProperties(),
$anon_distinct_id: machineId,
$set: {
...(properties || {}),
platform: 'desktop',
last_seen_at: new Date().toISOString(),
},
$set_once: {
first_seen_platform: 'desktop',
},
},
});
identifiedUserId = userId;
currentDistinctId = userId;
} catch {
// Analytics must never break the app
}
}
/**
* Reset user identity on logout. Subsequent events are captured anonymously
* against the machine ID until the user logs in again.
*/
export function resetUser(): void {
if (!client) return;
identifiedUserId = null;
currentDistinctId = machineId;
}
export function trackEvent(
event: string,
properties?: Record<string, unknown>
): void {
if (!client) return; if (!client) return;
try { try {
client.capture({ client.capture({
distinctId, distinctId: currentDistinctId || machineId,
event, event,
properties: { properties: {
platform: 'desktop', ...baseProperties(),
app_version: app.getVersion(),
os: process.platform,
...properties, ...properties,
}, },
}); });

View file

@ -0,0 +1,304 @@
import { app } from 'electron';
import fs from 'fs';
import os from 'os';
import path from 'path';
// ---------------------------------------------------------------------------
// Launch on system startup ("auto-launch" / "open at login").
//
// macOS + Windows : uses Electron's built-in `app.setLoginItemSettings()`.
// Linux : writes a freedesktop autostart `.desktop` file into
// `~/.config/autostart/`. Electron's API is a no-op there.
//
// The OS is the source of truth for whether we're enabled (so a user who
// disables us via System Settings / GNOME Tweaks isn't silently overridden).
// We persist a small companion record in electron-store for things the OS
// can't tell us — currently just `openAsHidden`, since on Windows we encode
// it as a CLI arg and on Linux as part of the Exec line, but on a fresh
// startup we still want the renderer toggle to reflect the user's intent.
// ---------------------------------------------------------------------------
const STORE_KEY = 'launchAtLogin';
const HIDDEN_FLAG = '--hidden';
const LINUX_DESKTOP_FILENAME = 'surfsense.desktop';
export interface AutoLaunchState {
enabled: boolean;
openAsHidden: boolean;
supported: boolean;
}
interface PersistedState {
enabled: boolean;
openAsHidden: boolean;
// True once we've run the first-launch defaults (opt-in to auto-launch).
// We never re-apply defaults if this is set, so a user who has explicitly
// turned auto-launch off stays off forever.
defaultsApplied: boolean;
}
const DEFAULTS: PersistedState = {
enabled: false,
openAsHidden: true,
defaultsApplied: false,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- lazily imported ESM module; matches shortcuts.ts pattern
let store: any = null;
async function getStore() {
if (!store) {
const { default: Store } = await import('electron-store');
store = new Store({
name: 'auto-launch',
defaults: { [STORE_KEY]: DEFAULTS },
});
}
return store;
}
async function readPersisted(): Promise<PersistedState> {
const s = await getStore();
const stored = s.get(STORE_KEY) as Partial<PersistedState> | undefined;
return { ...DEFAULTS, ...(stored ?? {}) };
}
async function writePersisted(next: PersistedState): Promise<void> {
const s = await getStore();
s.set(STORE_KEY, next);
}
// ---------------------------------------------------------------------------
// Platform support
// ---------------------------------------------------------------------------
// Auto-launch only makes sense for the packaged app — in dev `process.execPath`
// is the local Electron binary, so registering it would point the OS at a
// throwaway path the next time the dev server isn't running.
function isSupported(): boolean {
if (!app.isPackaged) return false;
return ['darwin', 'win32', 'linux'].includes(process.platform);
}
// ---------------------------------------------------------------------------
// Linux: ~/.config/autostart/surfsense.desktop
// ---------------------------------------------------------------------------
function linuxAutostartDir(): string {
const xdg = process.env.XDG_CONFIG_HOME;
const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.config');
return path.join(base, 'autostart');
}
function linuxAutostartFile(): string {
return path.join(linuxAutostartDir(), LINUX_DESKTOP_FILENAME);
}
// AppImages move around with the user — `process.execPath` points at a temp
// mount, so we have to use the original AppImage path exposed via env.
function linuxExecPath(): string {
return process.env.APPIMAGE && process.env.APPIMAGE.length > 0
? process.env.APPIMAGE
: process.execPath;
}
function escapeDesktopExecArg(value: string): string {
// Freedesktop `.desktop` Exec values require quoted args when spaces are
// present. We keep this intentionally minimal and escape only characters
// that can break quoted parsing.
return `"${value.replace(/(["\\`$])/g, '\\$1')}"`;
}
function writeLinuxDesktopFile(openAsHidden: boolean): void {
const exec = escapeDesktopExecArg(linuxExecPath());
const args = openAsHidden ? ` ${HIDDEN_FLAG}` : '';
const contents = [
'[Desktop Entry]',
'Type=Application',
'Version=1.0',
'Name=SurfSense',
'Comment=AI-powered research assistant',
`Exec=${exec}${args}`,
'Terminal=false',
'Categories=Utility;Office;',
'X-GNOME-Autostart-enabled=true',
`X-GNOME-Autostart-Delay=${openAsHidden ? '5' : '0'}`,
'',
].join('\n');
fs.mkdirSync(linuxAutostartDir(), { recursive: true });
fs.writeFileSync(linuxAutostartFile(), contents, { mode: 0o644 });
}
function removeLinuxDesktopFile(): void {
try {
fs.unlinkSync(linuxAutostartFile());
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err;
}
}
function readLinuxDesktopFile(): boolean {
return fs.existsSync(linuxAutostartFile());
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export async function getAutoLaunchState(): Promise<AutoLaunchState> {
const supported = isSupported();
const persisted = await readPersisted();
if (!supported) {
return { enabled: false, openAsHidden: persisted.openAsHidden, supported: false };
}
// Trust the OS state — the user may have disabled it from system settings.
return { enabled: readOsEnabled(), openAsHidden: persisted.openAsHidden, supported: true };
}
export async function setAutoLaunch(
enabled: boolean,
openAsHidden: boolean = DEFAULTS.openAsHidden,
): Promise<AutoLaunchState> {
const supported = isSupported();
if (!supported) {
return { enabled: false, openAsHidden, supported: false };
}
applySystemRegistration(enabled, openAsHidden);
// Preserve `defaultsApplied` (and any future fields) — and explicitly
// mark them as applied, since the user has now made an intentional choice.
await writePersisted({ enabled, openAsHidden, defaultsApplied: true });
return { enabled, openAsHidden, supported: true };
}
function applySystemRegistration(enabled: boolean, openAsHidden: boolean): void {
if (process.platform === 'linux') {
if (enabled) writeLinuxDesktopFile(openAsHidden);
else removeLinuxDesktopFile();
return;
}
if (!enabled) {
app.setLoginItemSettings({ openAtLogin: false });
return;
}
if (process.platform === 'win32') {
// On Windows we can't tell the OS to "launch hidden" — instead we pass an
// arg the app introspects on boot to skip showing the main window.
app.setLoginItemSettings({
openAtLogin: true,
args: openAsHidden ? [HIDDEN_FLAG] : [],
});
return;
}
// darwin
app.setLoginItemSettings({
openAtLogin: true,
openAsHidden,
});
}
// First-launch opt-in: register SurfSense as a hidden login item so the tray,
// global shortcuts, and folder watchers are ready right after the user signs
// in. Runs at most once per installation — the `defaultsApplied` flag is
// flipped before we ever touch the OS so a failure to register doesn't cause
// us to retry on every boot, and a user who turns the toggle off afterwards
// is never silently re-enabled.
//
// Returns whether the defaults were actually applied this boot, so callers
// can fire an analytics event without coupling this module to PostHog.
export async function applyAutoLaunchDefaults(): Promise<boolean> {
if (!isSupported()) return false;
const persisted = await readPersisted();
if (persisted.defaultsApplied) return false;
// Mark the defaults as applied *first*. If `applySystemRegistration`
// throws (e.g. read-only home dir on Linux), we'd rather silently leave
// the user un-registered than spam them with a failed registration on
// every single boot.
const next: PersistedState = {
enabled: true,
openAsHidden: true,
defaultsApplied: true,
};
try {
applySystemRegistration(true, true);
} catch (err) {
console.error('[auto-launch] First-run registration failed:', err);
next.enabled = false;
}
await writePersisted(next);
return next.enabled;
}
// Called once at startup. Goal:
// * If the OS-level entry is already enabled, re-assert it so a moved
// binary (Windows reinstall to a new dir, Linux AppImage moved by user)
// gets its registered path refreshed.
// * If the OS-level entry has been disabled — typically because the user
// turned it off in System Settings / GNOME Tweaks — *respect that* and
// reconcile our persisted state to match. We never silently re-enable
// a login item the user explicitly turned off.
export async function syncAutoLaunchOnStartup(): Promise<void> {
if (!isSupported()) return;
const persisted = await readPersisted();
const osEnabled = readOsEnabled();
if (!osEnabled) {
// User (or some other tool) turned us off out-of-band. Don't re-enable;
// just bring our persisted state in sync so the settings UI reflects
// reality on the next render.
if (persisted.enabled) {
await writePersisted({ ...persisted, enabled: false });
}
return;
}
// OS says we're enabled — refresh the registration so the recorded path /
// args match this binary. Idempotent on macOS; corrects path drift on
// Windows and Linux. If our persisted state was somehow stale we also
// bring it back in line.
try {
applySystemRegistration(true, persisted.openAsHidden);
if (!persisted.enabled) {
await writePersisted({ ...persisted, enabled: true });
}
} catch (err) {
console.error('[auto-launch] Failed to re-assert login item:', err);
}
}
function readOsEnabled(): boolean {
if (process.platform === 'linux') return readLinuxDesktopFile();
return app.getLoginItemSettings().openAtLogin;
}
// True when the OS launched us as part of login (used for analytics).
export function wasLaunchedAtLogin(): boolean {
if (process.argv.includes(HIDDEN_FLAG)) return true;
if (process.platform === 'darwin') {
const settings = app.getLoginItemSettings();
return settings.wasOpenedAtLogin || settings.wasOpenedAsHidden;
}
return false;
}
// Used for boot UI behavior. On macOS we only start hidden when the OS
// explicitly launched the app as hidden, not merely "at login".
export function shouldStartHidden(): boolean {
if (process.argv.includes(HIDDEN_FLAG)) return true;
if (process.platform === 'darwin') {
const settings = app.getLoginItemSettings();
return settings.wasOpenedAsHidden;
}
return false;
}

View file

@ -1,4 +1,5 @@
import { app, dialog } from 'electron'; import { app, dialog } from 'electron';
import { trackEvent } from './analytics';
const SEMVER_RE = /^\d+\.\d+\.\d+/; const SEMVER_RE = /^\d+\.\d+\.\d+/;
@ -17,10 +18,18 @@ export function setupAutoUpdater(): void {
autoUpdater.on('update-available', (info: { version: string }) => { autoUpdater.on('update-available', (info: { version: string }) => {
console.log(`Update available: ${info.version}`); console.log(`Update available: ${info.version}`);
trackEvent('desktop_update_available', {
current_version: version,
new_version: info.version,
});
}); });
autoUpdater.on('update-downloaded', (info: { version: string }) => { autoUpdater.on('update-downloaded', (info: { version: string }) => {
console.log(`Update downloaded: ${info.version}`); console.log(`Update downloaded: ${info.version}`);
trackEvent('desktop_update_downloaded', {
current_version: version,
new_version: info.version,
});
dialog.showMessageBox({ dialog.showMessageBox({
type: 'info', type: 'info',
buttons: ['Restart', 'Later'], buttons: ['Restart', 'Later'],
@ -29,13 +38,19 @@ export function setupAutoUpdater(): void {
message: `Version ${info.version} has been downloaded. Restart to apply the update.`, message: `Version ${info.version} has been downloaded. Restart to apply the update.`,
}).then(({ response }: { response: number }) => { }).then(({ response }: { response: number }) => {
if (response === 0) { if (response === 0) {
trackEvent('desktop_update_install_accepted', { new_version: info.version });
autoUpdater.quitAndInstall(); autoUpdater.quitAndInstall();
} else {
trackEvent('desktop_update_install_deferred', { new_version: info.version });
} }
}); });
}); });
autoUpdater.on('error', (err: Error) => { autoUpdater.on('error', (err: Error) => {
console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]); console.log('Auto-updater: update check skipped —', err.message?.split('\n')[0]);
trackEvent('desktop_update_error', {
message: err.message?.split('\n')[0],
});
}); });
autoUpdater.checkForUpdates().catch(() => {}); autoUpdater.checkForUpdates().catch(() => {});

View file

@ -2,6 +2,7 @@ import { app } from 'electron';
import path from 'path'; import path from 'path';
import { getMainWindow } from './window'; import { getMainWindow } from './window';
import { getServerPort } from './server'; import { getServerPort } from './server';
import { trackEvent } from './analytics';
const PROTOCOL = 'surfsense'; const PROTOCOL = 'surfsense';
@ -16,6 +17,10 @@ function handleDeepLink(url: string) {
if (!win) return; if (!win) return;
const parsed = new URL(url); const parsed = new URL(url);
trackEvent('desktop_deep_link_received', {
host: parsed.hostname,
path: parsed.pathname,
});
if (parsed.hostname === 'auth' && parsed.pathname === '/callback') { if (parsed.hostname === 'auth' && parsed.pathname === '/callback') {
const params = parsed.searchParams.toString(); const params = parsed.searchParams.toString();
win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`); win.loadURL(`http://localhost:${getServerPort()}/auth/callback?${params}`);
@ -64,3 +69,10 @@ export function handlePendingDeepLink(): void {
deepLinkUrl = null; deepLinkUrl = null;
} }
} }
// True when a deep link arrived before the main window existed. Callers can
// use this to force-create a window even on a "started hidden" boot, so we
// don't silently swallow a `surfsense://` URL the user actually clicked on.
export function hasPendingDeepLink(): boolean {
return deepLinkUrl !== null;
}

View file

@ -4,6 +4,7 @@ import { randomUUID } from 'crypto';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import { IPC_CHANNELS } from '../ipc/channels'; import { IPC_CHANNELS } from '../ipc/channels';
import { trackEvent } from './analytics';
export interface WatchedFolderConfig { export interface WatchedFolderConfig {
path: string; path: string;
@ -401,6 +402,15 @@ export async function addWatchedFolder(
await startWatcher(config); await startWatcher(config);
} }
trackEvent('desktop_folder_watch_added', {
search_space_id: config.searchSpaceId,
root_folder_id: config.rootFolderId,
active: config.active,
has_exclude_patterns: (config.excludePatterns?.length ?? 0) > 0,
has_extension_filter: !!config.fileExtensions && config.fileExtensions.length > 0,
is_update: existing >= 0,
});
return folders; return folders;
} }
@ -409,6 +419,7 @@ export async function removeWatchedFolder(
): Promise<WatchedFolderConfig[]> { ): Promise<WatchedFolderConfig[]> {
const s = await getStore(); const s = await getStore();
const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []); const folders: WatchedFolderConfig[] = s.get(STORE_KEY, []);
const removed = folders.find((f: WatchedFolderConfig) => f.path === folderPath);
const updated = folders.filter((f: WatchedFolderConfig) => f.path !== folderPath); const updated = folders.filter((f: WatchedFolderConfig) => f.path !== folderPath);
s.set(STORE_KEY, updated); s.set(STORE_KEY, updated);
@ -418,6 +429,13 @@ export async function removeWatchedFolder(
const ms = await getMtimeStore(); const ms = await getMtimeStore();
ms.delete(folderPath); ms.delete(folderPath);
if (removed) {
trackEvent('desktop_folder_watch_removed', {
search_space_id: removed.searchSpaceId,
root_folder_id: removed.rootFolderId,
});
}
return updated; return updated;
} }

View file

@ -2,6 +2,7 @@ import { app, globalShortcut, Menu, nativeImage, Tray } from 'electron';
import path from 'path'; import path from 'path';
import { getMainWindow, createMainWindow } from './window'; import { getMainWindow, createMainWindow } from './window';
import { getShortcuts } from './shortcuts'; import { getShortcuts } from './shortcuts';
import { trackEvent } from './analytics';
let tray: Tray | null = null; let tray: Tray | null = null;
let currentShortcut: string | null = null; let currentShortcut: string | null = null;
@ -15,14 +16,16 @@ function getTrayIcon(): nativeImage {
return img.resize({ width: 16, height: 16 }); return img.resize({ width: 16, height: 16 });
} }
function showMainWindow(): void { function showMainWindow(source: 'tray_click' | 'tray_menu' | 'shortcut' = 'tray_click'): void {
let win = getMainWindow(); const existing = getMainWindow();
if (!win || win.isDestroyed()) { const reopened = !existing || existing.isDestroyed();
win = createMainWindow('/dashboard'); if (reopened) {
createMainWindow('/dashboard');
} else { } else {
win.show(); existing.show();
win.focus(); existing.focus();
} }
trackEvent('desktop_main_window_shown', { source, reopened });
} }
function registerShortcut(accelerator: string): void { function registerShortcut(accelerator: string): void {
@ -32,7 +35,7 @@ function registerShortcut(accelerator: string): void {
} }
if (!accelerator) return; if (!accelerator) return;
try { try {
const ok = globalShortcut.register(accelerator, showMainWindow); const ok = globalShortcut.register(accelerator, () => showMainWindow('shortcut'));
if (ok) { if (ok) {
currentShortcut = accelerator; currentShortcut = accelerator;
} else { } else {
@ -50,13 +53,19 @@ export async function createTray(): Promise<void> {
tray.setToolTip('SurfSense'); tray.setToolTip('SurfSense');
const contextMenu = Menu.buildFromTemplate([ const contextMenu = Menu.buildFromTemplate([
{ label: 'Open SurfSense', click: showMainWindow }, { label: 'Open SurfSense', click: () => showMainWindow('tray_menu') },
{ type: 'separator' }, { type: 'separator' },
{ label: 'Quit', click: () => { app.exit(0); } }, {
label: 'Quit',
click: () => {
trackEvent('desktop_tray_quit_clicked');
app.exit(0);
},
},
]); ]);
tray.setContextMenu(contextMenu); tray.setContextMenu(contextMenu);
tray.on('double-click', showMainWindow); tray.on('double-click', () => showMainWindow('tray_click'));
const shortcuts = await getShortcuts(); const shortcuts = await getShortcuts();
registerShortcut(shortcuts.generalAssist); registerShortcut(shortcuts.generalAssist);

View file

@ -8,11 +8,18 @@ const isDev = !app.isPackaged;
const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string;
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let isQuitting = false;
export function getMainWindow(): BrowserWindow | null { export function getMainWindow(): BrowserWindow | null {
return mainWindow; return mainWindow;
} }
// Called from main.ts on `before-quit` so the close-to-tray handler knows
// to actually let the window die instead of hiding it.
export function markQuitting(): void {
isQuitting = true;
}
export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1280, width: 1280,
@ -70,6 +77,16 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools();
} }
// Hide-to-tray on close (don't actually destroy the window unless the
// user really is quitting). Applies to every instance — including the one
// created lazily after a launch-at-login boot.
mainWindow.on('close', (e) => {
if (!isQuitting && mainWindow) {
e.preventDefault();
mainWindow.hide();
}
});
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
mainWindow = null; mainWindow = null;
}); });

View file

@ -82,8 +82,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
setShortcuts: (config: Record<string, string>) => setShortcuts: (config: Record<string, string>) =>
ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config), ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config),
// Launch on system startup
getAutoLaunch: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTO_LAUNCH),
setAutoLaunch: (enabled: boolean, openAsHidden?: boolean) =>
ipcRenderer.invoke(IPC_CHANNELS.SET_AUTO_LAUNCH, { enabled, openAsHidden }),
// Active search space // Active search space
getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE), getActiveSearchSpace: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ACTIVE_SEARCH_SPACE),
setActiveSearchSpace: (id: string) => setActiveSearchSpace: (id: string) =>
ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id), ipcRenderer.invoke(IPC_CHANNELS.SET_ACTIVE_SEARCH_SPACE, id),
// Analytics bridge — lets posthog-js running inside the Next.js renderer
// mirror identify/reset/capture into the Electron main-process PostHog
// client so desktop-only events are attributed to the logged-in user.
analyticsIdentify: (userId: string, properties?: Record<string, unknown>) =>
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_IDENTIFY, { userId, properties }),
analyticsReset: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_RESET),
analyticsCapture: (event: string, properties?: Record<string, unknown>) =>
ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }),
getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT),
}); });

View file

@ -41,7 +41,7 @@ async function getAllModels(): Promise<AnonModel[]> {
function buildSeoTitle(model: AnonModel): string { function buildSeoTitle(model: AnonModel): string {
if (model.seo_title) return model.seo_title; if (model.seo_title) return model.seo_title;
return `${model.name} Free Online Without Login | No Sign-Up AI Chat | SurfSense`; return `Chat with ${model.name} Free, No Login | SurfSense`;
} }
function buildSeoDescription(model: AnonModel): string { function buildSeoDescription(model: AnonModel): string {

View file

@ -18,7 +18,7 @@ import type { AnonModel } from "@/contracts/types/anonymous-chat.types";
import { BACKEND_URL } from "@/lib/env-config"; import { BACKEND_URL } from "@/lib/env-config";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "ChatGPT Free Online Without Login | Chat GPT No Login, Claude AI Free | SurfSense", title: "Free AI Chat, No Login Required | SurfSense",
description: description:
"Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and more for free. No sign-up required. Open source NotebookLM alternative with free AI chat and document Q&A.", "Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and more for free. No sign-up required. Open source NotebookLM alternative with free AI chat and document Q&A.",
keywords: [ keywords: [
@ -67,7 +67,7 @@ export const metadata: Metadata = {
canonical: "https://surfsense.com/free", canonical: "https://surfsense.com/free",
}, },
openGraph: { openGraph: {
title: "ChatGPT Free Online Without Login | Claude AI Free No Login | SurfSense", title: "Free AI Chat, No Login Required | SurfSense",
description: description:
"Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and 100+ AI models. Open source NotebookLM alternative.", "Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and 100+ AI models. Open source NotebookLM alternative.",
url: "https://surfsense.com/free", url: "https://surfsense.com/free",
@ -84,7 +84,7 @@ export const metadata: Metadata = {
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "ChatGPT Free Online Without Login | Claude AI Free No Login | SurfSense", title: "Free AI Chat, No Login Required | SurfSense",
description: description:
"Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and more. No sign-up needed.", "Use ChatGPT free online without login. Chat with GPT-4, Claude AI, Gemini and more. No sign-up needed.",
images: ["/og-image.png"], images: ["/og-image.png"],

View file

@ -5,7 +5,10 @@ import type { Context } from "@/types/zero";
import { queries } from "@/zero/queries"; import { queries } from "@/zero/queries";
import { schema } from "@/zero/schema"; import { schema } from "@/zero/schema";
const backendURL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; const backendURL =
process.env.FASTAPI_BACKEND_INTERNAL_URL ||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ||
"http://localhost:8000";
async function authenticateRequest( async function authenticateRequest(
request: Request request: Request

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { BrainCog, Rocket, Zap } from "lucide-react"; import { BrainCog, Power, Rocket, Zap } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder"; import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder";
@ -30,6 +30,10 @@ export function DesktopContent() {
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]); const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null); const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(false);
const [autoLaunchHidden, setAutoLaunchHidden] = useState(true);
const [autoLaunchSupported, setAutoLaunchSupported] = useState(false);
useEffect(() => { useEffect(() => {
if (!api) { if (!api) {
setLoading(false); setLoading(false);
@ -38,19 +42,28 @@ export function DesktopContent() {
} }
let mounted = true; let mounted = true;
const hasAutoLaunchApi =
typeof api.getAutoLaunch === "function" && typeof api.setAutoLaunch === "function";
setAutoLaunchSupported(hasAutoLaunchApi);
Promise.all([ Promise.all([
api.getAutocompleteEnabled(), api.getAutocompleteEnabled(),
api.getShortcuts?.() ?? Promise.resolve(null), api.getShortcuts?.() ?? Promise.resolve(null),
api.getActiveSearchSpace?.() ?? Promise.resolve(null), api.getActiveSearchSpace?.() ?? Promise.resolve(null),
searchSpacesApiService.getSearchSpaces(), searchSpacesApiService.getSearchSpaces(),
hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null),
]) ])
.then(([autoEnabled, config, spaceId, spaces]) => { .then(([autoEnabled, config, spaceId, spaces, autoLaunch]) => {
if (!mounted) return; if (!mounted) return;
setEnabled(autoEnabled); setEnabled(autoEnabled);
if (config) setShortcuts(config); if (config) setShortcuts(config);
setActiveSpaceId(spaceId); setActiveSpaceId(spaceId);
if (spaces) setSearchSpaces(spaces); if (spaces) setSearchSpaces(spaces);
if (autoLaunch) {
setAutoLaunchEnabled(autoLaunch.enabled);
setAutoLaunchHidden(autoLaunch.openAsHidden);
setAutoLaunchSupported(autoLaunch.supported);
}
setLoading(false); setLoading(false);
setShortcutsLoaded(true); setShortcutsLoaded(true);
}) })
@ -106,6 +119,40 @@ export function DesktopContent() {
updateShortcut(key, DEFAULT_SHORTCUTS[key]); updateShortcut(key, DEFAULT_SHORTCUTS[key]);
}; };
const handleAutoLaunchToggle = async (checked: boolean) => {
if (!autoLaunchSupported || !api.setAutoLaunch) {
toast.error("Please update the desktop app to configure launch on startup");
return;
}
setAutoLaunchEnabled(checked);
try {
const next = await api.setAutoLaunch(checked, autoLaunchHidden);
if (next) {
setAutoLaunchEnabled(next.enabled);
setAutoLaunchHidden(next.openAsHidden);
setAutoLaunchSupported(next.supported);
}
toast.success(checked ? "SurfSense will launch on startup" : "Launch on startup disabled");
} catch {
setAutoLaunchEnabled(!checked);
toast.error("Failed to update launch on startup");
}
};
const handleAutoLaunchHiddenToggle = async (checked: boolean) => {
if (!autoLaunchSupported || !api.setAutoLaunch) {
toast.error("Please update the desktop app to configure startup behavior");
return;
}
setAutoLaunchHidden(checked);
try {
await api.setAutoLaunch(autoLaunchEnabled, checked);
} catch {
setAutoLaunchHidden(!checked);
toast.error("Failed to update startup behavior");
}
};
const handleSearchSpaceChange = (value: string) => { const handleSearchSpaceChange = (value: string) => {
setActiveSpaceId(value); setActiveSpaceId(value);
api.setActiveSearchSpace?.(value); api.setActiveSearchSpace?.(value);
@ -145,6 +192,59 @@ export function DesktopContent() {
</CardContent> </CardContent>
</Card> </Card>
{/* Launch on Startup */}
<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 flex items-center gap-2">
<Power className="h-4 w-4" />
Launch on Startup
</CardTitle>
<CardDescription className="text-xs md:text-sm">
Automatically start SurfSense when you sign in to your computer so global shortcuts and
folder sync are always available.
</CardDescription>
</CardHeader>
<CardContent className="px-3 md:px-6 pb-3 md:pb-6 space-y-3">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="auto-launch-toggle" className="text-sm font-medium cursor-pointer">
Open SurfSense at login
</Label>
<p className="text-xs text-muted-foreground">
{autoLaunchSupported
? "Adds SurfSense to your system's login items."
: "Only available in the packaged desktop app."}
</p>
</div>
<Switch
id="auto-launch-toggle"
checked={autoLaunchEnabled}
onCheckedChange={handleAutoLaunchToggle}
disabled={!autoLaunchSupported}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label
htmlFor="auto-launch-hidden-toggle"
className="text-sm font-medium cursor-pointer"
>
Start minimized to tray
</Label>
<p className="text-xs text-muted-foreground">
Skip the main window on boot SurfSense lives in the system tray until you need it.
</p>
</div>
<Switch
id="auto-launch-hidden-toggle"
checked={autoLaunchHidden}
onCheckedChange={handleAutoLaunchHiddenToggle}
disabled={!autoLaunchSupported || !autoLaunchEnabled}
/>
</div>
</CardContent>
</Card>
{/* Keyboard Shortcuts */} {/* Keyboard Shortcuts */}
<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">

View file

@ -1,7 +1,8 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useQueries } from "@tanstack/react-query";
import { ReceiptText } from "lucide-react"; import { Coins, FileText, ReceiptText } from "lucide-react";
import { useMemo } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import {
@ -12,10 +13,26 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import type { PagePurchase, PagePurchaseStatus } from "@/contracts/types/stripe.types"; import type {
PagePurchase,
PagePurchaseStatus,
TokenPurchase,
} from "@/contracts/types/stripe.types";
import { stripeApiService } from "@/lib/apis/stripe-api.service"; import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type PurchaseKind = "pages" | "tokens";
type UnifiedPurchase = {
id: string;
kind: PurchaseKind;
created_at: string;
status: PagePurchaseStatus;
granted: number;
amount_total: number | null;
currency: string | null;
};
const STATUS_STYLES: Record<PagePurchaseStatus, { label: string; className: string }> = { const STATUS_STYLES: Record<PagePurchaseStatus, { label: string; className: string }> = {
completed: { completed: {
label: "Completed", label: "Completed",
@ -31,6 +48,22 @@ const STATUS_STYLES: Record<PagePurchaseStatus, { label: string; className: stri
}, },
}; };
const KIND_META: Record<
PurchaseKind,
{ label: string; icon: React.ComponentType<{ className?: string }>; iconClass: string }
> = {
pages: {
label: "Pages",
icon: FileText,
iconClass: "text-sky-500",
},
tokens: {
label: "Premium Tokens",
icon: Coins,
iconClass: "text-amber-500",
},
};
function formatDate(iso: string): string { function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { return new Date(iso).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
@ -39,19 +72,63 @@ function formatDate(iso: string): string {
}); });
} }
function formatAmount(purchase: PagePurchase): string { function formatAmount(amount: number | null, currency: string | null): string {
if (purchase.amount_total == null) return "—"; if (amount == null) return "—";
const dollars = purchase.amount_total / 100; const dollars = amount / 100;
const currency = (purchase.currency ?? "usd").toUpperCase(); const code = (currency ?? "usd").toUpperCase();
return `$${dollars.toFixed(2)} ${currency}`; return `$${dollars.toFixed(2)} ${code}`;
}
function normalizePagePurchase(p: PagePurchase): UnifiedPurchase {
return {
id: p.id,
kind: "pages",
created_at: p.created_at,
status: p.status,
granted: p.pages_granted,
amount_total: p.amount_total,
currency: p.currency,
};
}
function normalizeTokenPurchase(p: TokenPurchase): UnifiedPurchase {
return {
id: p.id,
kind: "tokens",
created_at: p.created_at,
status: p.status,
granted: p.tokens_granted,
amount_total: p.amount_total,
currency: p.currency,
};
} }
export function PurchaseHistoryContent() { export function PurchaseHistoryContent() {
const { data, isLoading } = useQuery({ const results = useQueries({
queryKey: ["stripe-purchases"], queries: [
queryFn: () => stripeApiService.getPurchases(), {
queryKey: ["stripe-purchases"],
queryFn: () => stripeApiService.getPurchases(),
},
{
queryKey: ["stripe-token-purchases"],
queryFn: () => stripeApiService.getTokenPurchases(),
},
],
}); });
const [pagesQuery, tokensQuery] = results;
const isLoading = pagesQuery.isLoading || tokensQuery.isLoading;
const purchases = useMemo<UnifiedPurchase[]>(() => {
const pagePurchases = pagesQuery.data?.purchases ?? [];
const tokenPurchases = tokensQuery.data?.purchases ?? [];
return [
...pagePurchases.map(normalizePagePurchase),
...tokenPurchases.map(normalizeTokenPurchase),
].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
}, [pagesQuery.data, tokensQuery.data]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@ -60,15 +137,13 @@ export function PurchaseHistoryContent() {
); );
} }
const purchases = data?.purchases ?? [];
if (purchases.length === 0) { if (purchases.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center"> <div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
<ReceiptText className="h-8 w-8 text-muted-foreground" /> <ReceiptText className="h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium">No purchases yet</p> <p className="text-sm font-medium">No purchases yet</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Your page-pack purchases will appear here after checkout. Your page and premium token purchases will appear here after checkout.
</p> </p>
</div> </div>
); );
@ -81,25 +156,36 @@ export function PurchaseHistoryContent() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Date</TableHead> <TableHead>Date</TableHead>
<TableHead className="text-right">Pages</TableHead> <TableHead>Type</TableHead>
<TableHead className="text-right">Granted</TableHead>
<TableHead className="text-right">Amount</TableHead> <TableHead className="text-right">Amount</TableHead>
<TableHead className="text-center">Status</TableHead> <TableHead className="text-center">Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{purchases.map((p) => { {purchases.map((p) => {
const style = STATUS_STYLES[p.status]; const statusStyle = STATUS_STYLES[p.status];
const kind = KIND_META[p.kind];
const KindIcon = kind.icon;
return ( return (
<TableRow key={p.id}> <TableRow key={`${p.kind}-${p.id}`}>
<TableCell className="text-sm">{formatDate(p.created_at)}</TableCell> <TableCell className="text-sm">{formatDate(p.created_at)}</TableCell>
<TableCell className="text-right tabular-nums text-sm"> <TableCell className="text-sm">
{p.pages_granted.toLocaleString()} <div className="flex items-center gap-2">
<KindIcon className={cn("h-4 w-4", kind.iconClass)} />
<span>{kind.label}</span>
</div>
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums text-sm"> <TableCell className="text-right tabular-nums text-sm">
{formatAmount(p)} {p.granted.toLocaleString()}
</TableCell>
<TableCell className="text-right tabular-nums text-sm">
{formatAmount(p.amount_total, p.currency)}
</TableCell> </TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Badge className={cn("text-[10px]", style.className)}>{style.label}</Badge> <Badge className={cn("text-[10px]", statusStyle.className)}>
{statusStyle.label}
</Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
@ -108,7 +194,8 @@ export function PurchaseHistoryContent() {
</Table> </Table>
</div> </div>
<p className="text-center text-xs text-muted-foreground"> <p className="text-center text-xs text-muted-foreground">
Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}. Showing your {purchases.length} most recent purchase
{purchases.length !== 1 ? "s" : ""}.
</p> </p>
</div> </div>
); );

View file

@ -45,7 +45,7 @@ export const metadata: Metadata = {
alternates: { alternates: {
canonical: "https://surfsense.com", canonical: "https://surfsense.com",
}, },
title: "SurfSense - NotebookLM Alternative | Free ChatGPT & Claude AI", title: "SurfSense Open Source, Privacy-Focused NotebookLM Alternative for Teams",
description: description:
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.", "Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.",
keywords: [ keywords: [
@ -87,7 +87,7 @@ export const metadata: Metadata = {
"SurfSense", "SurfSense",
], ],
openGraph: { openGraph: {
title: "SurfSense - NotebookLM Alternative | Free ChatGPT & Claude AI", title: "SurfSense Open Source, Privacy-Focused NotebookLM Alternative for Teams",
description: description:
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude, and any AI model for free.", "Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude, and any AI model for free.",
url: "https://surfsense.com", url: "https://surfsense.com",
@ -105,7 +105,7 @@ export const metadata: Metadata = {
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "SurfSense - NotebookLM Alternative | Free ChatGPT & Claude AI", title: "SurfSense Open Source, Privacy-Focused NotebookLM Alternative for Teams",
description: description:
"Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.", "Open source NotebookLM alternative for teams with no data limits. Use ChatGPT, Claude AI, and any AI model for free.",
creator: "@SurfSenseAI", creator: "@SurfSenseAI",

View file

@ -303,5 +303,79 @@ export const AUTO_INDEX_DEFAULTS: Record<string, AutoIndexConfig> = {
export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(Object.keys(AUTO_INDEX_DEFAULTS)); export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(Object.keys(AUTO_INDEX_DEFAULTS));
// ============================================================================
// CONNECTOR TELEMETRY REGISTRY
// ----------------------------------------------------------------------------
// Single source of truth for "what does this connector_type look like in
// analytics?". Any connector added to the lists above is automatically
// picked up here, so adding a new integration does NOT require touching
// `lib/posthog/events.ts` or per-connector tracking code.
// ============================================================================
export type ConnectorTelemetryGroup = "oauth" | "composio" | "crawler" | "other" | "unknown";
export interface ConnectorTelemetryMeta {
connector_type: string;
connector_title: string;
connector_group: ConnectorTelemetryGroup;
is_oauth: boolean;
}
const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap<string, ConnectorTelemetryMeta> = (() => {
const map = new Map<string, ConnectorTelemetryMeta>();
for (const c of OAUTH_CONNECTORS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "oauth",
is_oauth: true,
});
}
for (const c of COMPOSIO_CONNECTORS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "composio",
is_oauth: true,
});
}
for (const c of CRAWLERS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "crawler",
is_oauth: false,
});
}
for (const c of OTHER_CONNECTORS) {
map.set(c.connectorType, {
connector_type: c.connectorType,
connector_title: c.title,
connector_group: "other",
is_oauth: false,
});
}
return map;
})();
/**
* Returns telemetry metadata for a connector_type, or a minimal "unknown"
* record so tracking never no-ops for connectors that exist in the backend
* but were forgotten in the UI registry.
*/
export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelemetryMeta {
const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType);
if (hit) return hit;
return {
connector_type: connectorType,
connector_title: connectorType,
connector_group: "unknown",
is_oauth: false,
};
}
// Re-export IndexingConfigState from schemas for backward compatibility // Re-export IndexingConfigState from schemas for backward compatibility
export type { IndexingConfigState } from "./connector-popup.schemas"; export type { IndexingConfigState } from "./connector-popup.schemas";

View file

@ -24,6 +24,8 @@ import { isSelfHosted } from "@/lib/env-config";
import { import {
trackConnectorConnected, trackConnectorConnected,
trackConnectorDeleted, trackConnectorDeleted,
trackConnectorSetupFailure,
trackConnectorSetupStarted,
trackIndexWithDateRangeOpened, trackIndexWithDateRangeOpened,
trackIndexWithDateRangeStarted, trackIndexWithDateRangeStarted,
trackPeriodicIndexingStarted, trackPeriodicIndexingStarted,
@ -232,10 +234,20 @@ export const useConnectorDialog = () => {
if (result.error) { if (result.error) {
const oauthConnector = result.connector const oauthConnector = result.connector
? OAUTH_CONNECTORS.find((c) => c.id === result.connector) ? OAUTH_CONNECTORS.find((c) => c.id === result.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === result.connector)
: null; : null;
const name = oauthConnector?.title || "connector"; const name = oauthConnector?.title || "connector";
if (oauthConnector) {
trackConnectorSetupFailure(
Number(searchSpaceId),
oauthConnector.connectorType,
result.error,
"oauth_callback"
);
}
if (result.error === "duplicate_account") { if (result.error === "duplicate_account") {
toast.error(`This ${name} account is already connected`, { toast.error(`This ${name} account is already connected`, {
description: "Please use a different account or manage the existing connection.", description: "Please use a different account or manage the existing connection.",
@ -351,6 +363,8 @@ export const useConnectorDialog = () => {
// Set connecting state immediately to disable button and show spinner // Set connecting state immediately to disable button and show spinner
setConnectingId(connector.id); setConnectingId(connector.id);
trackConnectorSetupStarted(Number(searchSpaceId), connector.connectorType, "oauth_click");
try { try {
// Check if authEndpoint already has query parameters // Check if authEndpoint already has query parameters
const separator = connector.authEndpoint.includes("?") ? "&" : "?"; const separator = connector.authEndpoint.includes("?") ? "&" : "?";
@ -372,6 +386,12 @@ export const useConnectorDialog = () => {
window.location.href = validatedData.auth_url; window.location.href = validatedData.auth_url;
} catch (error) { } catch (error) {
console.error(`Error connecting to ${connector.title}:`, error); console.error(`Error connecting to ${connector.title}:`, error);
trackConnectorSetupFailure(
Number(searchSpaceId),
connector.connectorType,
error instanceof Error ? error.message : "oauth_initiation_failed",
"oauth_init"
);
if (error instanceof Error && error.message.includes("Invalid auth URL")) { if (error instanceof Error && error.message.includes("Invalid auth URL")) {
toast.error(`Invalid response from ${connector.title} OAuth endpoint`); toast.error(`Invalid response from ${connector.title} OAuth endpoint`);
} else { } else {
@ -395,6 +415,11 @@ export const useConnectorDialog = () => {
if (!searchSpaceId) return; if (!searchSpaceId) return;
setConnectingId("webcrawler-connector"); setConnectingId("webcrawler-connector");
trackConnectorSetupStarted(
Number(searchSpaceId),
EnumConnectorName.WEBCRAWLER_CONNECTOR,
"webcrawler_quick_add"
);
try { try {
await createConnector({ await createConnector({
data: { data: {
@ -444,6 +469,12 @@ export const useConnectorDialog = () => {
} }
} catch (error) { } catch (error) {
console.error("Error creating webcrawler connector:", error); console.error("Error creating webcrawler connector:", error);
trackConnectorSetupFailure(
Number(searchSpaceId),
EnumConnectorName.WEBCRAWLER_CONNECTOR,
error instanceof Error ? error.message : "webcrawler_create_failed",
"webcrawler_quick_add"
);
toast.error("Failed to create web crawler connector"); toast.error("Failed to create web crawler connector");
} finally { } finally {
setConnectingId(null); setConnectingId(null);
@ -455,6 +486,8 @@ export const useConnectorDialog = () => {
(connectorType: string) => { (connectorType: string) => {
if (!searchSpaceId) return; if (!searchSpaceId) return;
trackConnectorSetupStarted(Number(searchSpaceId), connectorType, "non_oauth_click");
// Handle Obsidian specifically on Desktop & Cloud // Handle Obsidian specifically on Desktop & Cloud
if (connectorType === EnumConnectorName.OBSIDIAN_CONNECTOR && !selfHosted && isDesktop) { if (connectorType === EnumConnectorName.OBSIDIAN_CONNECTOR && !selfHosted && isDesktop) {
setIsOpen(false); setIsOpen(false);
@ -683,6 +716,12 @@ export const useConnectorDialog = () => {
} }
} catch (error) { } catch (error) {
console.error("Error creating connector:", error); console.error("Error creating connector:", error);
trackConnectorSetupFailure(
Number(searchSpaceId),
connectingConnectorType ?? formData.connector_type,
error instanceof Error ? error.message : "connector_create_failed",
"non_oauth_form"
);
toast.error(error instanceof Error ? error.message : "Failed to create connector"); toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally { } finally {
isCreatingConnectorRef.current = false; isCreatingConnectorRef.current = false;

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -29,12 +29,16 @@ export function CreateFolderDialog({
const [name, setName] = useState(""); const [name, setName] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { const handleOpenChange = useCallback(
if (open) { (next: boolean) => {
setName(""); if (next) {
setTimeout(() => inputRef.current?.focus(), 0); setName("");
} setTimeout(() => inputRef.current?.focus(), 0);
}, [open]); }
onOpenChange(next);
},
[onOpenChange]
);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(e?: React.FormEvent) => { (e?: React.FormEvent) => {
@ -50,7 +54,7 @@ export function CreateFolderDialog({
const isSubfolder = !!parentFolderName; const isSubfolder = !!parentFolderName;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none"> <DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
<DialogHeader className="space-y-2 pb-2"> <DialogHeader className="space-y-2 pb-2">
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { ChevronDown, ChevronRight, Folder, FolderOpen, Home } from "lucide-react"; import { ChevronDown, ChevronRight, Folder, FolderOpen, Home } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@ -36,12 +36,16 @@ export function FolderPickerDialog({
const [selectedId, setSelectedId] = useState<number | null>(null); const [selectedId, setSelectedId] = useState<number | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set()); const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
useEffect(() => { const handleOpenChange = useCallback(
if (open) { (next: boolean) => {
setSelectedId(null); if (next) {
setExpandedIds(new Set()); setSelectedId(null);
} setExpandedIds(new Set());
}, [open]); }
onOpenChange(next);
},
[onOpenChange]
);
const foldersByParent = useMemo(() => { const foldersByParent = useMemo(() => {
const map: Record<string, FolderDisplay[]> = {}; const map: Record<string, FolderDisplay[]> = {};
@ -123,7 +127,7 @@ export function FolderPickerDialog({
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none"> <DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
<DialogHeader className="space-y-2 pb-2"> <DialogHeader className="space-y-2 pb-2">
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">

View file

@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types"; import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { readSSEStream } from "@/lib/chat/streaming-state"; import { readSSEStream } from "@/lib/chat/streaming-state";
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { QuotaBar } from "./quota-bar"; import { QuotaBar } from "./quota-bar";
import { QuotaWarningBanner } from "./quota-warning-banner"; import { QuotaWarningBanner } from "./quota-warning-banner";
@ -61,6 +62,12 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
textareaRef.current.style.height = "auto"; textareaRef.current.style.height = "auto";
} }
trackAnonymousChatMessageSent({
modelSlug: model.seo_slug,
messageLength: trimmed.length,
surface: "free_model_page",
});
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;

View file

@ -28,6 +28,7 @@ import {
updateToolCall, updateToolCall,
} from "@/lib/chat/streaming-state"; } from "@/lib/chat/streaming-state";
import { BACKEND_URL } from "@/lib/env-config"; import { BACKEND_URL } from "@/lib/env-config";
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { FreeModelSelector } from "./free-model-selector"; import { FreeModelSelector } from "./free-model-selector";
import { FreeThread } from "./free-thread"; import { FreeThread } from "./free-thread";
@ -206,6 +207,13 @@ export function FreeChatPage() {
} }
if (!userQuery.trim()) return; if (!userQuery.trim()) return;
trackAnonymousChatMessageSent({
modelSlug,
messageLength: userQuery.trim().length,
hasUploadedDoc: anonMode.isAnonymous && anonMode.uploadedDoc !== null ? true : false,
surface: "free_chat_page",
});
const userMsgId = `msg-user-${Date.now()}`; const userMsgId = `msg-user-${Date.now()}`;
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,

View file

@ -27,13 +27,14 @@ export function FreeModelSelector({ className }: { className?: string }) {
anonymousChatApiService.getModels().then(setModels).catch(console.error); anonymousChatApiService.getModels().then(setModels).catch(console.error);
}, []); }, []);
useEffect(() => { const handleOpenChange = useCallback((next: boolean) => {
if (open) { if (next) {
setSearchQuery(""); setSearchQuery("");
setFocusedIndex(-1); setFocusedIndex(-1);
requestAnimationFrame(() => searchInputRef.current?.focus()); requestAnimationFrame(() => searchInputRef.current?.focus());
} }
}, [open]); setOpen(next);
}, []);
const currentModel = useMemo( const currentModel = useMemo(
() => models.find((m) => m.seo_slug === currentSlug) ?? null, () => models.find((m) => m.seo_slug === currentSlug) ?? null,
@ -94,7 +95,7 @@ export function FreeModelSelector({ className }: { className?: string }) {
); );
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"

View file

@ -65,16 +65,15 @@ function EmailsTagField({
setTags((prev) => (typeof newTags === "function" ? newTags(prev) : newTags)); setTags((prev) => (typeof newTags === "function" ? newTags(prev) : newTags));
}, []); }, []);
const handleAddTag = useCallback( const handleAddTag = useCallback((text: string) => {
(text: string) => { const trimmed = text.trim();
const trimmed = text.trim(); if (!trimmed) return;
if (!trimmed) return; setTags((prev) => {
if (tags.some((tag) => tag.text === trimmed)) return; if (prev.some((tag) => tag.text === trimmed)) return prev;
const newTag: TagType = { id: Date.now().toString(), text: trimmed }; const newTag: TagType = { id: Date.now().toString(), text: trimmed };
setTags((prev) => [...prev, newTag]); return [...prev, newTag];
}, });
[tags] }, []);
);
return ( return (
<TagInput <TagInput

View file

@ -426,15 +426,50 @@ const AiSortIllustration = () => (
<title>AI File Sorting illustration showing automatic folder organization</title> <title>AI File Sorting illustration showing automatic folder organization</title>
{/* Scattered documents on the left */} {/* Scattered documents on the left */}
<g opacity="0.5"> <g opacity="0.5">
<rect x="20" y="40" width="35" height="45" rx="4" className="fill-neutral-200 dark:fill-neutral-700" transform="rotate(-8 37 62)" /> <rect
<rect x="50" y="80" width="35" height="45" rx="4" className="fill-neutral-200 dark:fill-neutral-700" transform="rotate(5 67 102)" /> x="20"
<rect x="15" y="110" width="35" height="45" rx="4" className="fill-neutral-200 dark:fill-neutral-700" transform="rotate(-3 32 132)" /> y="40"
width="35"
height="45"
rx="4"
className="fill-neutral-200 dark:fill-neutral-700"
transform="rotate(-8 37 62)"
/>
<rect
x="50"
y="80"
width="35"
height="45"
rx="4"
className="fill-neutral-200 dark:fill-neutral-700"
transform="rotate(5 67 102)"
/>
<rect
x="15"
y="110"
width="35"
height="45"
rx="4"
className="fill-neutral-200 dark:fill-neutral-700"
transform="rotate(-3 32 132)"
/>
</g> </g>
{/* AI sparkle / magic in the center */} {/* AI sparkle / magic in the center */}
<g transform="translate(140, 90)"> <g transform="translate(140, 90)">
<path d="M 0,-18 L 4,-6 L 16,-4 L 6,4 L 8,16 L 0,10 L -8,16 L -6,4 L -16,-4 L -4,-6 Z" className="fill-emerald-500 dark:fill-emerald-400" opacity="0.85"> <path
<animateTransform attributeName="transform" type="rotate" from="0" to="360" dur="10s" repeatCount="indefinite" /> d="M 0,-18 L 4,-6 L 16,-4 L 6,4 L 8,16 L 0,10 L -8,16 L -6,4 L -16,-4 L -4,-6 Z"
className="fill-emerald-500 dark:fill-emerald-400"
opacity="0.85"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0"
to="360"
dur="10s"
repeatCount="indefinite"
/>
</path> </path>
<circle cx="0" cy="0" r="3" className="fill-white dark:fill-emerald-200"> <circle cx="0" cy="0" r="3" className="fill-white dark:fill-emerald-200">
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite" /> <animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite" />
@ -442,51 +477,208 @@ const AiSortIllustration = () => (
</g> </g>
{/* Animated sorting arrows */} {/* Animated sorting arrows */}
<g className="stroke-emerald-500 dark:stroke-emerald-400" strokeWidth="2" fill="none" opacity="0.6"> <g
className="stroke-emerald-500 dark:stroke-emerald-400"
strokeWidth="2"
fill="none"
opacity="0.6"
>
<path d="M 100 70 Q 140 60, 180 50" strokeDasharray="4,4"> <path d="M 100 70 Q 140 60, 180 50" strokeDasharray="4,4">
<animate attributeName="stroke-dashoffset" from="8" to="0" dur="1s" repeatCount="indefinite" /> <animate
attributeName="stroke-dashoffset"
from="8"
to="0"
dur="1s"
repeatCount="indefinite"
/>
</path> </path>
<path d="M 100 100 Q 140 100, 180 100" strokeDasharray="4,4"> <path d="M 100 100 Q 140 100, 180 100" strokeDasharray="4,4">
<animate attributeName="stroke-dashoffset" from="8" to="0" dur="1s" repeatCount="indefinite" /> <animate
attributeName="stroke-dashoffset"
from="8"
to="0"
dur="1s"
repeatCount="indefinite"
/>
</path> </path>
<path d="M 100 130 Q 140 140, 180 150" strokeDasharray="4,4"> <path d="M 100 130 Q 140 140, 180 150" strokeDasharray="4,4">
<animate attributeName="stroke-dashoffset" from="8" to="0" dur="1s" repeatCount="indefinite" /> <animate
attributeName="stroke-dashoffset"
from="8"
to="0"
dur="1s"
repeatCount="indefinite"
/>
</path> </path>
</g> </g>
{/* Organized folder tree on the right */} {/* Organized folder tree on the right */}
{/* Root folder */} {/* Root folder */}
<g> <g>
<rect x="220" y="30" width="160" height="28" rx="6" className="fill-white dark:fill-neutral-800" opacity="0.9" /> <rect
<rect x="228" y="36" width="16" height="14" rx="3" className="fill-emerald-500 dark:fill-emerald-400" /> x="220"
<line x1="252" y1="43" x2="330" y2="43" className="stroke-neutral-400 dark:stroke-neutral-500" strokeWidth="2.5" strokeLinecap="round" /> y="30"
width="160"
height="28"
rx="6"
className="fill-white dark:fill-neutral-800"
opacity="0.9"
/>
<rect
x="228"
y="36"
width="16"
height="14"
rx="3"
className="fill-emerald-500 dark:fill-emerald-400"
/>
<line
x1="252"
y1="43"
x2="330"
y2="43"
className="stroke-neutral-400 dark:stroke-neutral-500"
strokeWidth="2.5"
strokeLinecap="round"
/>
</g> </g>
{/* Subfolder 1 */} {/* Subfolder 1 */}
<g> <g>
<line x1="240" y1="58" x2="240" y2="76" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" /> <line
<line x1="240" y1="76" x2="250" y2="76" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" /> x1="240"
<rect x="250" y="64" width="130" height="24" rx="5" className="fill-white dark:fill-neutral-800" opacity="0.85" /> y1="58"
<rect x="257" y="70" width="12" height="11" rx="2" className="fill-teal-400 dark:fill-teal-500" /> x2="240"
<line x1="276" y1="76" x2="340" y2="76" className="stroke-neutral-400 dark:stroke-neutral-500" strokeWidth="2" strokeLinecap="round" /> y2="76"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
<line
x1="240"
y1="76"
x2="250"
y2="76"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
<rect
x="250"
y="64"
width="130"
height="24"
rx="5"
className="fill-white dark:fill-neutral-800"
opacity="0.85"
/>
<rect
x="257"
y="70"
width="12"
height="11"
rx="2"
className="fill-teal-400 dark:fill-teal-500"
/>
<line
x1="276"
y1="76"
x2="340"
y2="76"
className="stroke-neutral-400 dark:stroke-neutral-500"
strokeWidth="2"
strokeLinecap="round"
/>
</g> </g>
{/* Subfolder 2 */} {/* Subfolder 2 */}
<g> <g>
<line x1="240" y1="76" x2="240" y2="108" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" /> <line
<line x1="240" y1="108" x2="250" y2="108" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" /> x1="240"
<rect x="250" y="96" width="130" height="24" rx="5" className="fill-white dark:fill-neutral-800" opacity="0.85" /> y1="76"
<rect x="257" y="102" width="12" height="11" rx="2" className="fill-cyan-400 dark:fill-cyan-500" /> x2="240"
<line x1="276" y1="108" x2="350" y2="108" className="stroke-neutral-400 dark:stroke-neutral-500" strokeWidth="2" strokeLinecap="round" /> y2="108"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
<line
x1="240"
y1="108"
x2="250"
y2="108"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
<rect
x="250"
y="96"
width="130"
height="24"
rx="5"
className="fill-white dark:fill-neutral-800"
opacity="0.85"
/>
<rect
x="257"
y="102"
width="12"
height="11"
rx="2"
className="fill-cyan-400 dark:fill-cyan-500"
/>
<line
x1="276"
y1="108"
x2="350"
y2="108"
className="stroke-neutral-400 dark:stroke-neutral-500"
strokeWidth="2"
strokeLinecap="round"
/>
</g> </g>
{/* Subfolder 3 */} {/* Subfolder 3 */}
<g> <g>
<line x1="240" y1="108" x2="240" y2="140" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" /> <line
<line x1="240" y1="140" x2="250" y2="140" className="stroke-neutral-300 dark:stroke-neutral-600" strokeWidth="1.5" /> x1="240"
<rect x="250" y="128" width="130" height="24" rx="5" className="fill-white dark:fill-neutral-800" opacity="0.85" /> y1="108"
<rect x="257" y="134" width="12" height="11" rx="2" className="fill-emerald-400 dark:fill-emerald-500" /> x2="240"
<line x1="276" y1="140" x2="325" y2="140" className="stroke-neutral-400 dark:stroke-neutral-500" strokeWidth="2" strokeLinecap="round" /> y2="140"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
<line
x1="240"
y1="140"
x2="250"
y2="140"
className="stroke-neutral-300 dark:stroke-neutral-600"
strokeWidth="1.5"
/>
<rect
x="250"
y="128"
width="130"
height="24"
rx="5"
className="fill-white dark:fill-neutral-800"
opacity="0.85"
/>
<rect
x="257"
y="134"
width="12"
height="11"
rx="2"
className="fill-emerald-400 dark:fill-emerald-500"
/>
<line
x1="276"
y1="140"
x2="325"
y2="140"
className="stroke-neutral-400 dark:stroke-neutral-500"
strokeWidth="2"
strokeLinecap="round"
/>
</g> </g>
{/* Sparkle accents */} {/* Sparkle accents */}
@ -495,10 +687,22 @@ const AiSortIllustration = () => (
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" /> <animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
</circle> </circle>
<circle cx="190" cy="155" r="1.5" className="fill-teal-400"> <circle cx="190" cy="155" r="1.5" className="fill-teal-400">
<animate attributeName="opacity" values="0;1;0" dur="2.5s" begin="0.8s" repeatCount="indefinite" /> <animate
attributeName="opacity"
values="0;1;0"
dur="2.5s"
begin="0.8s"
repeatCount="indefinite"
/>
</circle> </circle>
<circle cx="155" cy="120" r="1.5" className="fill-cyan-400"> <circle cx="155" cy="120" r="1.5" className="fill-cyan-400">
<animate attributeName="opacity" values="0;1;0" dur="3s" begin="0.4s" repeatCount="indefinite" /> <animate
attributeName="opacity"
values="0;1;0"
dur="3s"
begin="0.4s"
repeatCount="indefinite"
/>
</circle> </circle>
</g> </g>
</svg> </svg>

View file

@ -2,8 +2,10 @@
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import dynamic from "next/dynamic";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
import { Spinner } from "@/components/ui/spinner";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import type { InboxItem } from "@/hooks/use-inbox"; import type { InboxItem } from "@/hooks/use-inbox";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
@ -25,9 +27,20 @@ import {
Sidebar, Sidebar,
} from "../sidebar"; } from "../sidebar";
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel"; import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
import { DocumentTabContent } from "../tabs/DocumentTabContent";
import { TabBar } from "../tabs/TabBar"; import { TabBar } from "../tabs/TabBar";
const DocumentTabContent = dynamic(
() => import("../tabs/DocumentTabContent").then((m) => ({ default: m.DocumentTabContent })),
{
ssr: false,
loading: () => (
<div className="flex-1 flex items-center justify-center h-full">
<Spinner size="lg" />
</div>
),
}
);
// Per-tab data source // Per-tab data source
interface TabDataSource { interface TabDataSource {
items: InboxItem[]; items: InboxItem[];

View file

@ -478,7 +478,7 @@ function AuthenticatedDocumentsSidebar({
setFolderPickerOpen(true); setFolderPickerOpen(true);
}, []); }, []);
const [, setIsExportingKB] = useState(false); const isExportingKBRef = useRef(false);
const [exportWarningOpen, setExportWarningOpen] = useState(false); const [exportWarningOpen, setExportWarningOpen] = useState(false);
const [exportWarningContext, setExportWarningContext] = useState<{ const [exportWarningContext, setExportWarningContext] = useState<{
folder: FolderDisplay; folder: FolderDisplay;
@ -508,7 +508,7 @@ function AuthenticatedDocumentsSidebar({
const ctx = exportWarningContext; const ctx = exportWarningContext;
if (!ctx?.folder) return; if (!ctx?.folder) return;
setIsExportingKB(true); isExportingKBRef.current = true;
try { try {
const safeName = const safeName =
ctx.folder.name ctx.folder.name
@ -524,7 +524,7 @@ function AuthenticatedDocumentsSidebar({
console.error("Folder export failed:", err); console.error("Folder export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed"); toast.error(err instanceof Error ? err.message : "Export failed");
} finally { } finally {
setIsExportingKB(false); isExportingKBRef.current = false;
} }
setExportWarningContext(null); setExportWarningContext(null);
}, [exportWarningContext, searchSpaceId, doExport]); }, [exportWarningContext, searchSpaceId, doExport]);
@ -560,7 +560,7 @@ function AuthenticatedDocumentsSidebar({
return; return;
} }
setIsExportingKB(true); isExportingKBRef.current = true;
try { try {
const safeName = const safeName =
folder.name folder.name
@ -576,7 +576,7 @@ function AuthenticatedDocumentsSidebar({
console.error("Folder export failed:", err); console.error("Folder export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed"); toast.error(err instanceof Error ? err.message : "Export failed");
} finally { } finally {
setIsExportingKB(false); isExportingKBRef.current = false;
} }
}, },
[searchSpaceId, getPendingCountInSubtree, doExport] [searchSpaceId, getPendingCountInSubtree, doExport]

View file

@ -269,6 +269,34 @@ export function ModelSelector({
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const handleOpenChange = useCallback(
(next: boolean) => {
if (next) {
setSearchQuery("");
setSelectedProvider("all");
if (!isMobile) {
requestAnimationFrame(() => searchInputRef.current?.focus());
}
}
setOpen(next);
},
[isMobile]
);
const handleTabChange = useCallback(
(next: "llm" | "image" | "vision") => {
setActiveTab(next);
setSelectedProvider("all");
setSearchQuery("");
setFocusedIndex(-1);
setModelScrollPos("top");
if (open && !isMobile) {
requestAnimationFrame(() => searchInputRef.current?.focus());
}
},
[open, isMobile]
);
const handleModelListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => { const handleModelListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const el = e.currentTarget; const el = e.currentTarget;
const atTop = el.scrollTop <= 2; const atTop = el.scrollTop <= 2;
@ -292,43 +320,19 @@ export function ModelSelector({
[isMobile] [isMobile]
); );
// Reset search + provider when tab changes
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger
useEffect(() => {
setSelectedProvider("all");
setSearchQuery("");
setFocusedIndex(-1);
setModelScrollPos("top");
}, [activeTab]);
// Reset on open
useEffect(() => {
if (open) {
setSearchQuery("");
setSelectedProvider("all");
}
}, [open]);
// Cmd/Ctrl+M shortcut (desktop only) // Cmd/Ctrl+M shortcut (desktop only)
useEffect(() => { useEffect(() => {
if (isMobile) return; if (isMobile) return;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "m") { if ((e.metaKey || e.ctrlKey) && e.key === "m") {
e.preventDefault(); e.preventDefault();
setOpen((prev) => !prev); // setOpen((prev) => !prev);
handleOpenChange(!open);
} }
}; };
document.addEventListener("keydown", handler); document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler);
}, [isMobile]); }, [isMobile, open, handleOpenChange]);
// Focus search input on open
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger to re-focus on tab switch
useEffect(() => {
if (open && !isMobile) {
requestAnimationFrame(() => searchInputRef.current?.focus());
}
}, [open, isMobile, activeTab]);
// ─── Data ─── // ─── Data ───
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom); const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
@ -971,7 +975,8 @@ export function ModelSelector({
<button <button
key={value} key={value}
type="button" type="button"
onClick={() => setActiveTab(value)} // onClick={() => setActiveTab(value)}
onClick={() => handleTabChange(value)}
className={cn( className={cn(
"flex items-center justify-center gap-1.5 text-sm font-medium transition-all duration-200 border-b-[1.5px]", "flex items-center justify-center gap-1.5 text-sm font-medium transition-all duration-200 border-b-[1.5px]",
activeTab === value activeTab === value
@ -1208,7 +1213,7 @@ export function ModelSelector({
// ─── Shell: Drawer on mobile, Popover on desktop ─── // ─── Shell: Drawer on mobile, Popover on desktop ───
if (isMobile) { if (isMobile) {
return ( return (
<Drawer open={open} onOpenChange={setOpen}> <Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerTrigger asChild>{triggerButton}</DrawerTrigger> <DrawerTrigger asChild>{triggerButton}</DrawerTrigger>
<DrawerContent className="max-h-[85vh]"> <DrawerContent className="max-h-[85vh]">
<DrawerHandle /> <DrawerHandle />
@ -1222,7 +1227,7 @@ export function ModelSelector({
} }
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger> <PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
<PopoverContent <PopoverContent
className="w-[300px] md:w-[380px] p-0 rounded-lg shadow-lg overflow-hidden bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none" className="w-[300px] md:w-[380px] p-0 rounded-lg shadow-lg overflow-hidden bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"

View file

@ -546,35 +546,36 @@ export function DocumentUploadTab({
</button> </button>
) )
) : ( ) : (
<div // biome-ignore lint/a11y/useSemanticElements: cannot use <button> here because the contents include nested interactive elements (renderBrowseButton renders a Button), which would be invalid HTML.
role="button" <div
tabIndex={0} role="button"
className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent outline-none select-none" tabIndex={0}
onClick={() => { className="flex flex-col items-center gap-4 py-12 px-4 cursor-pointer w-full bg-transparent outline-none select-none"
if (!isElectron) fileInputRef.current?.click(); onClick={() => {
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!isElectron) fileInputRef.current?.click(); if (!isElectron) fileInputRef.current?.click();
} }}
}} onKeyDown={(e) => {
> if (e.key === "Enter" || e.key === " ") {
<Upload className="h-10 w-10 text-muted-foreground" /> e.preventDefault();
<div className="text-center space-y-1.5"> if (!isElectron) fileInputRef.current?.click();
<p className="text-base font-medium"> }
{isElectron ? t("select_files_or_folder") : t("tap_select_files_or_folder")} }}
</p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div>
<fieldset
className="w-full mt-1 border-none p-0 m-0"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
> >
{renderBrowseButton({ fullWidth: true })} <Upload className="h-10 w-10 text-muted-foreground" />
</fieldset> <div className="text-center space-y-1.5">
</div> <p className="text-base font-medium">
{isElectron ? t("select_files_or_folder") : t("tap_select_files_or_folder")}
</p>
<p className="text-sm text-muted-foreground">{t("file_size_limit")}</p>
</div>
<fieldset
className="w-full mt-1 border-none p-0 m-0"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{renderBrowseButton({ fullWidth: true })}
</fieldset>
</div>
)} )}
</div> </div>

View file

@ -586,7 +586,7 @@ export const useThemeToggle = ({
}, []); }, []);
const toggleTheme = useCallback(() => { const toggleTheme = useCallback(() => {
setIsDark(!isDark); setIsDark((prev) => !prev);
const animation = createAnimation(variant, start, blur, gifUrl); const animation = createAnimation(variant, start, blur, gifUrl);
@ -604,7 +604,7 @@ export const useThemeToggle = ({
} }
document.startViewTransition(switchTheme); document.startViewTransition(switchTheme);
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]); }, [theme, setTheme, variant, start, blur, gifUrl, updateStyles]);
const setCrazyLightTheme = useCallback(() => { const setCrazyLightTheme = useCallback(() => {
setIsDark(false); setIsDark(false);

View file

@ -1,6 +1,6 @@
{ {
"title": "How to", "title": "How to",
"pages": ["zero-sync", "realtime-collaboration", "web-search"], "pages": ["zero-sync", "realtime-collaboration", "web-search", "ollama"],
"icon": "Compass", "icon": "Compass",
"defaultOpen": false "defaultOpen": false
} }

View file

@ -0,0 +1,90 @@
---
title: Connect Ollama
description: Simple setup guide for using Ollama with SurfSense across local, Docker, remote, and cloud setups
---
# Connect Ollama
Use this page to choose the correct **API Base URL** when adding an Ollama provider in SurfSense.
## 1) Pick your API Base URL
| Ollama location | SurfSense location | API Base URL |
|---|---|---|
| Same machine | No Docker | `http://localhost:11434` |
| Host machine (macOS/Windows) | Docker Desktop | `http://host.docker.internal:11434` |
| Host machine (Linux) | Docker Compose | `http://host.docker.internal:11434` |
| Same Docker Compose stack | Docker Compose | `http://ollama:11434` |
| Another machine in your network | Any | `http://<lan-ip>:11434` |
| Public Ollama endpoint / proxy / cloud | Any | `http(s)://<your-domain-or-endpoint>` |
If SurfSense runs in Docker, do not use `localhost` unless Ollama is in the same container.
## 2) Add Ollama in SurfSense
Go to **Search Space Settings -> Agent Models -> Add Model** and set:
- Provider: `OLLAMA`
- Model name: your model tag, for example `llama3.2` or `qwen3:8b`
- API Base URL: from the table above
- API key:
- local/self-hosted Ollama: any non-empty value
- Ollama cloud/proxied auth: real key or token required by that endpoint
Save. SurfSense validates the connection immediately.
## 3) Common setups
### A) SurfSense in Docker Desktop, Ollama on your host
Use:
```text
http://host.docker.internal:11434
```
### B) Ollama as a service in the same Compose
Use API Base URL:
```text
http://ollama:11434
```
Minimal service example:
```yaml
ollama:
image: ollama/ollama:latest
volumes:
- ollama_data:/root/.ollama
ports:
- "11434:11434"
```
### C) Ollama on another machine
Ollama binds to `127.0.0.1` by default. Make it reachable on the network:
- Set `OLLAMA_HOST=0.0.0.0:11434` on the machine/service running Ollama
- Open firewall port `11434`
- Use `http://<lan-ip>:11434` in SurfSense's API Base URL
## 4) Quick troubleshooting
| Error | Cause | Fix |
|---|---|---|
| `Cannot connect to host localhost:11434` | Wrong URL from Dockerized backend | Use `host.docker.internal` or `ollama` |
| `Cannot connect to host <lan-ip>:11434` | Ollama not exposed on network or firewall blocked | Set `OLLAMA_HOST=0.0.0.0:11434`, allow port 11434 |
| URL starts with `/%20http://...` | Leading space in URL | Re-enter API Base URL without spaces |
| `model not found` | Model not pulled on Ollama | Run `ollama pull <model>` |
If needed, test from the backend container using the same host you put in **API Base URL**:
```bash
docker compose exec backend curl -v <YOUR_API_BASE_URL>/api/tags
```
## See also
- [Docker Installation](/docs/docker-installation/docker-compose)

View file

@ -49,6 +49,8 @@ export const tokenStripeStatusResponse = z.object({
premium_tokens_remaining: z.number().default(0), premium_tokens_remaining: z.number().default(0),
}); });
export const tokenPurchaseStatusEnum = pagePurchaseStatusEnum;
export const tokenPurchase = z.object({ export const tokenPurchase = z.object({
id: z.uuid(), id: z.uuid(),
stripe_checkout_session_id: z.string(), stripe_checkout_session_id: z.string(),
@ -57,7 +59,7 @@ export const tokenPurchase = z.object({
tokens_granted: z.number(), tokens_granted: z.number(),
amount_total: z.number().nullable(), amount_total: z.number().nullable(),
currency: z.string().nullable(), currency: z.string().nullable(),
status: z.string(), status: tokenPurchaseStatusEnum,
completed_at: z.string().nullable(), completed_at: z.string().nullable(),
created_at: z.string(), created_at: z.string(),
}); });
@ -75,5 +77,6 @@ export type GetPagePurchasesResponse = z.infer<typeof getPagePurchasesResponse>;
export type CreateTokenCheckoutSessionRequest = z.infer<typeof createTokenCheckoutSessionRequest>; export type CreateTokenCheckoutSessionRequest = z.infer<typeof createTokenCheckoutSessionRequest>;
export type CreateTokenCheckoutSessionResponse = z.infer<typeof createTokenCheckoutSessionResponse>; export type CreateTokenCheckoutSessionResponse = z.infer<typeof createTokenCheckoutSessionResponse>;
export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse>; export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse>;
export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>;
export type TokenPurchase = z.infer<typeof tokenPurchase>; export type TokenPurchase = z.infer<typeof tokenPurchase>;
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>; export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;

View file

@ -1,18 +1,65 @@
import posthog from "posthog-js"; import posthog from "posthog-js";
function initPostHog() { /**
* PostHog initialisation for the Next.js renderer.
*
* The same bundle ships in two contexts:
* 1. A normal browser session on surfsense.com -> platform = "web"
* 2. The Electron desktop app (renders the Next app from localhost)
* -> platform = "desktop"
*
* When running inside Electron we also seed `posthog-js` with the main
* process's machine distinctId so that events fired from both the renderer
* (e.g. `chat_message_sent`, page views) and the Electron main process
* (e.g. `desktop_quick_ask_opened`) share a single PostHog person before
* login, and can be merged into the authenticated user afterwards.
*/
function isElectron(): boolean {
return typeof window !== "undefined" && !!window.electronAPI;
}
function currentPlatform(): "desktop" | "web" {
return isElectron() ? "desktop" : "web";
}
async function resolveBootstrapDistinctId(): Promise<string | undefined> {
if (!isElectron() || !window.electronAPI?.getAnalyticsContext) return undefined;
try {
const ctx = await window.electronAPI.getAnalyticsContext();
return ctx?.machineId || ctx?.distinctId || undefined;
} catch {
return undefined;
}
}
async function initPostHog() {
try { try {
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return; if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return;
const platform = currentPlatform();
const bootstrapDistinctId = await resolveBootstrapDistinctId();
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: "https://assets.surfsense.com", api_host: "https://assets.surfsense.com",
ui_host: "https://us.posthog.com", ui_host: "https://us.posthog.com",
defaults: "2026-01-30", defaults: "2026-01-30",
capture_pageview: "history_change", capture_pageview: "history_change",
capture_pageleave: true, capture_pageleave: true,
...(bootstrapDistinctId
? {
bootstrap: {
distinctID: bootstrapDistinctId,
isIdentifiedID: false,
},
}
: {}),
before_send: (event) => { before_send: (event) => {
if (event?.properties) { if (event?.properties) {
event.properties.platform = "web"; event.properties.platform = platform;
if (platform === "desktop") {
event.properties.is_desktop = true;
}
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const ref = params.get("ref"); const ref = params.get("ref");
@ -30,9 +77,14 @@ function initPostHog() {
event.properties.$set = { event.properties.$set = {
...event.properties.$set, ...event.properties.$set,
platform: "web", platform,
last_seen_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
}; };
event.properties.$set_once = {
...event.properties.$set_once,
first_seen_platform: platform,
};
} }
return event; return event;
}, },
@ -51,8 +103,12 @@ if (typeof window !== "undefined") {
window.posthog = posthog; window.posthog = posthog;
if ("requestIdleCallback" in window) { if ("requestIdleCallback" in window) {
requestIdleCallback(initPostHog); requestIdleCallback(() => {
void initPostHog();
});
} else { } else {
setTimeout(initPostHog, 3500); setTimeout(() => {
void initPostHog();
}, 3500);
} }
} }

View file

@ -1,4 +1,5 @@
import posthog from "posthog-js"; import posthog from "posthog-js";
import { getConnectorTelemetryMeta } from "@/components/assistant-ui/connector-popup/constants/connector-constants";
/** /**
* PostHog Analytics Event Definitions * PostHog Analytics Event Definitions
@ -13,8 +14,8 @@ import posthog from "posthog-js";
* - auth: Authentication events * - auth: Authentication events
* - search_space: Search space management * - search_space: Search space management
* - document: Document management * - document: Document management
* - chat: Chat and messaging * - chat: Chat and messaging (authenticated + anonymous)
* - connector: External connector events * - connector: External connector events (all lifecycle stages)
* - contact: Contact form events * - contact: Contact form events
* - settings: Settings changes * - settings: Settings changes
* - marketing: Marketing/referral tracking * - marketing: Marketing/referral tracking
@ -28,6 +29,17 @@ function safeCapture(event: string, properties?: Record<string, unknown>) {
} }
} }
/**
* Drop undefined values so PostHog doesn't log `"foo": undefined` noise.
*/
function compact<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined) out[k] = v;
}
return out;
}
// ============================================ // ============================================
// AUTH EVENTS // AUTH EVENTS
// ============================================ // ============================================
@ -127,6 +139,28 @@ export function trackChatError(searchSpaceId: number, chatId: number, error?: st
}); });
} }
/**
* Track a message sent from the unauthenticated "free" / anonymous chat
* flow. This is intentionally a separate event from `chat_message_sent`
* so WAU / retention queries on the authenticated event stay clean while
* still giving us visibility into top-of-funnel usage on /free/*.
*/
export function trackAnonymousChatMessageSent(options: {
modelSlug: string;
messageLength?: number;
hasUploadedDoc?: boolean;
webSearchEnabled?: boolean;
surface?: "free_chat_page" | "free_model_page";
}) {
safeCapture("anonymous_chat_message_sent", {
model_slug: options.modelSlug,
message_length: options.messageLength,
has_uploaded_doc: options.hasUploadedDoc ?? false,
web_search_enabled: options.webSearchEnabled,
surface: options.surface,
});
}
// ============================================ // ============================================
// DOCUMENT EVENTS // DOCUMENT EVENTS
// ============================================ // ============================================
@ -179,37 +213,88 @@ export function trackYouTubeImport(searchSpaceId: number, url: string) {
} }
// ============================================ // ============================================
// CONNECTOR EVENTS // CONNECTOR EVENTS (generic lifecycle dispatcher)
// ============================================ // ============================================
//
// All connector events go through `trackConnectorEvent`. The connector's
// human-readable title and its group (oauth/composio/crawler/other) are
// auto-attached from the shared registry in `connector-constants.ts`, so
// adding a new connector to that list is the only change required for it
// to show up correctly in PostHog dashboards.
export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) { export type ConnectorEventStage =
safeCapture("connector_setup_started", { | "setup_started"
search_space_id: searchSpaceId, | "setup_success"
connector_type: connectorType, | "setup_failure"
| "oauth_initiated"
| "connected"
| "deleted"
| "synced";
export interface ConnectorEventOptions {
searchSpaceId?: number | null;
connectorId?: number | null;
/** Source of the action (e.g. "oauth_callback", "non_oauth_form", "webcrawler_quick_add"). */
source?: string;
/** Free-form error message for failure events. */
error?: string;
/** Extra properties specific to the stage (e.g. frequency_minutes for sync events). */
extra?: Record<string, unknown>;
}
/**
* Generic connector lifecycle tracker. Every connector analytics event
* should funnel through here so the enrichment stays consistent.
*/
export function trackConnectorEvent(
stage: ConnectorEventStage,
connectorType: string,
options: ConnectorEventOptions = {}
) {
const meta = getConnectorTelemetryMeta(connectorType);
safeCapture(`connector_${stage}`, {
...compact({
search_space_id: options.searchSpaceId ?? undefined,
connector_id: options.connectorId ?? undefined,
source: options.source,
error: options.error,
}),
connector_type: meta.connector_type,
connector_title: meta.connector_title,
connector_group: meta.connector_group,
is_oauth: meta.is_oauth,
...(options.extra ?? {}),
}); });
} }
// ---- Convenience wrappers kept for backward compatibility ----
export function trackConnectorSetupStarted(
searchSpaceId: number,
connectorType: string,
source?: string
) {
trackConnectorEvent("setup_started", connectorType, { searchSpaceId, source });
}
export function trackConnectorSetupSuccess( export function trackConnectorSetupSuccess(
searchSpaceId: number, searchSpaceId: number,
connectorType: string, connectorType: string,
connectorId: number connectorId: number
) { ) {
safeCapture("connector_setup_success", { trackConnectorEvent("setup_success", connectorType, { searchSpaceId, connectorId });
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
});
} }
export function trackConnectorSetupFailure( export function trackConnectorSetupFailure(
searchSpaceId: number, searchSpaceId: number | null | undefined,
connectorType: string, connectorType: string,
error?: string error?: string,
source?: string
) { ) {
safeCapture("connector_setup_failure", { trackConnectorEvent("setup_failure", connectorType, {
search_space_id: searchSpaceId, searchSpaceId: searchSpaceId ?? undefined,
connector_type: connectorType,
error, error,
source,
}); });
} }
@ -218,11 +303,7 @@ export function trackConnectorDeleted(
connectorType: string, connectorType: string,
connectorId: number connectorId: number
) { ) {
safeCapture("connector_deleted", { trackConnectorEvent("deleted", connectorType, { searchSpaceId, connectorId });
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
});
} }
export function trackConnectorSynced( export function trackConnectorSynced(
@ -230,11 +311,7 @@ export function trackConnectorSynced(
connectorType: string, connectorType: string,
connectorId: number connectorId: number
) { ) {
safeCapture("connector_synced", { trackConnectorEvent("synced", connectorType, { searchSpaceId, connectorId });
search_space_id: searchSpaceId,
connector_type: connectorType,
connector_id: connectorId,
});
} }
// ============================================ // ============================================
@ -345,10 +422,9 @@ export function trackConnectorConnected(
connectorType: string, connectorType: string,
connectorId?: number connectorId?: number
) { ) {
safeCapture("connector_connected", { trackConnectorEvent("connected", connectorType, {
search_space_id: searchSpaceId, searchSpaceId,
connector_type: connectorType, connectorId: connectorId ?? undefined,
connector_id: connectorId,
}); });
} }
@ -467,8 +543,13 @@ export function trackReferralLanding(refCode: string, landingUrl: string) {
// ============================================ // ============================================
/** /**
* Identify a user for PostHog analytics * Identify a user for PostHog analytics.
* Call this after successful authentication * Call this after successful authentication.
*
* In the Electron desktop app the same call is mirrored into the
* main-process PostHog client so desktop-only events (e.g.
* `desktop_quick_ask_opened`, `desktop_autocomplete_accepted`) are
* attributed to the logged-in user rather than an anonymous machine ID.
*/ */
export function identifyUser(userId: string, properties?: Record<string, unknown>) { export function identifyUser(userId: string, properties?: Record<string, unknown>) {
try { try {
@ -476,10 +557,19 @@ export function identifyUser(userId: string, properties?: Record<string, unknown
} catch { } catch {
// Silently ignore ad-blockers may break posthog // Silently ignore ad-blockers may break posthog
} }
try {
if (typeof window !== "undefined" && window.electronAPI?.analyticsIdentify) {
void window.electronAPI.analyticsIdentify(userId, properties);
}
} catch {
// IPC errors must never break the app
}
} }
/** /**
* Reset user identity (call on logout) * Reset user identity (call on logout). Mirrors the reset into the
* Electron main process when running inside the desktop app.
*/ */
export function resetUser() { export function resetUser() {
try { try {
@ -487,4 +577,12 @@ export function resetUser() {
} catch { } catch {
// Silently ignore ad-blockers may break posthog // Silently ignore ad-blockers may break posthog
} }
try {
if (typeof window !== "undefined" && window.electronAPI?.analyticsReset) {
void window.electronAPI.analyticsReset();
}
} catch {
// IPC errors must never break the app
}
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "surfsense_web", "name": "surfsense_web",
"version": "0.0.16", "version": "0.0.19",
"private": true, "private": true,
"description": "SurfSense Frontend", "description": "SurfSense Frontend",
"scripts": { "scripts": {

View file

@ -1088,10 +1088,6 @@ packages:
peerDependencies: peerDependencies:
'@babel/core': ^7.0.0-0 '@babel/core': ^7.0.0-0
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@babel/runtime@7.29.2': '@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -2188,12 +2184,6 @@ packages:
peerDependencies: peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0' '@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@2.5.1':
resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.0.0 <1.10.0'
'@opentelemetry/core@2.6.0': '@opentelemetry/core@2.6.0':
resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==}
engines: {node: ^18.19.0 || >=20.6.0} engines: {node: ^18.19.0 || >=20.6.0}
@ -2606,12 +2596,6 @@ packages:
peerDependencies: peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0' '@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/resources@2.5.1':
resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': '>=1.3.0 <1.10.0'
'@opentelemetry/resources@2.6.0': '@opentelemetry/resources@2.6.0':
resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==}
engines: {node: ^18.19.0 || >=20.6.0} engines: {node: ^18.19.0 || >=20.6.0}
@ -4372,8 +4356,8 @@ packages:
peerDependencies: peerDependencies:
react: '>= 16' react: '>= 16'
'@tabler/icons@3.37.1': '@tabler/icons@3.41.1':
resolution: {integrity: sha512-neLCWkuyNHEPXCyYu6nbN4S3g/59BTa4qyITAugYVpq1YzYNDOZooW7/vRWH98ZItXAudxdKU8muFT7y1PqzuA==} resolution: {integrity: sha512-OaRnVbRmH2nHtFeg+RmMJ/7m2oBIF9XCJAUD5gQnMrpK9f05ydj8MZrAf3NZQqOXyxGN1UBL0D5IKLLEUfr74Q==}
'@tailwindcss/node@4.2.1': '@tailwindcss/node@4.2.1':
resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
@ -4814,6 +4798,7 @@ packages:
'@xmldom/xmldom@0.8.11': '@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
abstract-logging@2.0.1: abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@ -7012,11 +6997,6 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
nanoid@5.1.6:
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
engines: {node: ^18 || >=20}
hasBin: true
nanoid@5.1.7: nanoid@5.1.7:
resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==}
engines: {node: ^18 || >=20} engines: {node: ^18 || >=20}
@ -7421,8 +7401,8 @@ packages:
property-information@7.1.0: property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
protobufjs@7.5.4: protobufjs@7.5.5:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} resolution: {integrity: sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
proxy-compare@2.6.0: proxy-compare@2.6.0:
@ -9387,8 +9367,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@babel/runtime@7.28.6': {}
'@babel/runtime@7.29.2': {} '@babel/runtime@7.29.2': {}
'@babel/standalone@7.29.2': {} '@babel/standalone@7.29.2': {}
@ -9886,7 +9864,7 @@ snapshots:
dependencies: dependencies:
lodash.camelcase: 4.3.0 lodash.camelcase: 4.3.0
long: 5.3.2 long: 5.3.2
protobufjs: 7.5.4 protobufjs: 7.5.5
yargs: 17.7.2 yargs: 17.7.2
'@hookform/resolvers@5.2.2(react-hook-form@7.71.2(react@19.2.4))': '@hookform/resolvers@5.2.2(react-hook-form@7.71.2(react@19.2.4))':
@ -10246,7 +10224,7 @@ snapshots:
'@opentelemetry/resource-detector-azure': 0.10.0(@opentelemetry/api@1.9.0) '@opentelemetry/resource-detector-azure': 0.10.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resource-detector-container': 0.7.11(@opentelemetry/api@1.9.0) '@opentelemetry/resource-detector-container': 0.7.11(@opentelemetry/api@1.9.0)
'@opentelemetry/resource-detector-gcp': 0.37.0(@opentelemetry/api@1.9.0) '@opentelemetry/resource-detector-gcp': 0.37.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-node': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': 0.203.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@ -10270,11 +10248,6 @@ snapshots:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
@ -10397,7 +10370,7 @@ snapshots:
'@opentelemetry/instrumentation-amqplib@0.50.0(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-amqplib@0.50.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
transitivePeerDependencies: transitivePeerDependencies:
@ -10415,7 +10388,7 @@ snapshots:
'@opentelemetry/instrumentation-aws-sdk@0.58.0(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-aws-sdk@0.58.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
transitivePeerDependencies: transitivePeerDependencies:
@ -10441,7 +10414,7 @@ snapshots:
'@opentelemetry/instrumentation-connect@0.47.0(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-connect@0.47.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
'@types/connect': 3.4.38 '@types/connect': 3.4.38
@ -10473,7 +10446,7 @@ snapshots:
'@opentelemetry/instrumentation-express@0.52.0(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-express@0.52.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
transitivePeerDependencies: transitivePeerDependencies:
@ -10482,7 +10455,7 @@ snapshots:
'@opentelemetry/instrumentation-fastify@0.48.0(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-fastify@0.48.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
transitivePeerDependencies: transitivePeerDependencies:
@ -10491,7 +10464,7 @@ snapshots:
'@opentelemetry/instrumentation-fs@0.23.0(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-fs@0.23.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -10521,7 +10494,7 @@ snapshots:
'@opentelemetry/instrumentation-hapi@0.50.0(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-hapi@0.50.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
transitivePeerDependencies: transitivePeerDependencies:
@ -10565,7 +10538,7 @@ snapshots:
'@opentelemetry/instrumentation-koa@0.51.0(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-koa@0.51.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
transitivePeerDependencies: transitivePeerDependencies:
@ -10598,7 +10571,7 @@ snapshots:
'@opentelemetry/instrumentation-mongoose@0.50.0(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-mongoose@0.50.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
transitivePeerDependencies: transitivePeerDependencies:
@ -10650,7 +10623,7 @@ snapshots:
'@opentelemetry/instrumentation-pg@0.56.1(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-pg@0.56.1(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0)
@ -10663,7 +10636,7 @@ snapshots:
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.203.0 '@opentelemetry/api-logs': 0.203.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -10680,7 +10653,7 @@ snapshots:
'@opentelemetry/instrumentation-restify@0.49.0(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-restify@0.49.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
transitivePeerDependencies: transitivePeerDependencies:
@ -10721,7 +10694,7 @@ snapshots:
'@opentelemetry/instrumentation-undici@0.14.0(@opentelemetry/api@1.9.0)': '@opentelemetry/instrumentation-undici@0.14.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -10772,7 +10745,7 @@ snapshots:
'@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0)
protobufjs: 7.5.4 protobufjs: 7.5.5
'@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)': '@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
@ -10783,7 +10756,7 @@ snapshots:
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.0)
protobufjs: 7.5.4 protobufjs: 7.5.5
'@opentelemetry/propagator-b3@2.0.1(@opentelemetry/api@1.9.0)': '@opentelemetry/propagator-b3@2.0.1(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
@ -10800,34 +10773,34 @@ snapshots:
'@opentelemetry/resource-detector-alibaba-cloud@0.31.11(@opentelemetry/api@1.9.0)': '@opentelemetry/resource-detector-alibaba-cloud@0.31.11(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resource-detector-aws@2.13.0(@opentelemetry/api@1.9.0)': '@opentelemetry/resource-detector-aws@2.13.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/resource-detector-azure@0.10.0(@opentelemetry/api@1.9.0)': '@opentelemetry/resource-detector-azure@0.10.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/resource-detector-container@0.7.11(@opentelemetry/api@1.9.0)': '@opentelemetry/resource-detector-container@0.7.11(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resource-detector-gcp@0.37.0(@opentelemetry/api@1.9.0)': '@opentelemetry/resource-detector-gcp@0.37.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
gcp-metadata: 6.1.1 gcp-metadata: 6.1.1
transitivePeerDependencies: transitivePeerDependencies:
@ -10846,12 +10819,6 @@ snapshots:
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0)
'@opentelemetry/semantic-conventions': 1.39.0
'@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
@ -10952,7 +10919,7 @@ snapshots:
'@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0)
'@orama/orama@3.1.18': {} '@orama/orama@3.1.18': {}
@ -11067,7 +11034,7 @@ snapshots:
jotai-optics: 0.4.0(jotai@2.8.4(@types/react@19.2.14)(react@19.2.4))(optics-ts@2.4.1) jotai-optics: 0.4.0(jotai@2.8.4(@types/react@19.2.14)(react@19.2.4))(optics-ts@2.4.1)
jotai-x: 2.3.3(@types/react@19.2.14)(jotai@2.8.4(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) jotai-x: 2.3.3(@types/react@19.2.14)(jotai@2.8.4(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
lodash: 4.17.23 lodash: 4.17.23
nanoid: 5.1.6 nanoid: 5.1.7
optics-ts: 2.4.1 optics-ts: 2.4.1
react: 19.2.4 react: 19.2.4
react-compiler-runtime: 1.0.0(react@19.2.4) react-compiler-runtime: 1.0.0(react@19.2.4)
@ -11265,11 +11232,11 @@ snapshots:
'@radix-ui/primitive@1.0.0': '@radix-ui/primitive@1.0.0':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/primitive@1.0.1': '@radix-ui/primitive@1.0.1':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/primitive@1.1.3': {} '@radix-ui/primitive@1.1.3': {}
@ -11403,12 +11370,12 @@ snapshots:
'@radix-ui/react-compose-refs@1.0.0(react@19.2.4)': '@radix-ui/react-compose-refs@1.0.0(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
react: 19.2.4 react: 19.2.4
'@radix-ui/react-compose-refs@1.0.1(@types/react@19.2.14)(react@19.2.4)': '@radix-ui/react-compose-refs@1.0.1(@types/react@19.2.14)(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
react: 19.2.4 react: 19.2.4
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
@ -11435,12 +11402,12 @@ snapshots:
'@radix-ui/react-context@1.0.0(react@19.2.4)': '@radix-ui/react-context@1.0.0(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
react: 19.2.4 react: 19.2.4
'@radix-ui/react-context@1.0.1(@types/react@19.2.14)(react@19.2.4)': '@radix-ui/react-context@1.0.1(@types/react@19.2.14)(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
react: 19.2.4 react: 19.2.4
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
@ -11459,7 +11426,7 @@ snapshots:
'@radix-ui/react-dialog@1.0.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-dialog@1.0.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/primitive': 1.0.0 '@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0(react@19.2.4) '@radix-ui/react-compose-refs': 1.0.0(react@19.2.4)
'@radix-ui/react-context': 1.0.0(react@19.2.4) '@radix-ui/react-context': 1.0.0(react@19.2.4)
@ -11481,7 +11448,7 @@ snapshots:
'@radix-ui/react-dialog@1.0.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-dialog@1.0.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/primitive': 1.0.1 '@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-context': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-context': 1.0.1(@types/react@19.2.14)(react@19.2.4)
@ -11532,7 +11499,7 @@ snapshots:
'@radix-ui/react-dismissable-layer@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-dismissable-layer@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/primitive': 1.0.0 '@radix-ui/primitive': 1.0.0
'@radix-ui/react-compose-refs': 1.0.0(react@19.2.4) '@radix-ui/react-compose-refs': 1.0.0(react@19.2.4)
'@radix-ui/react-primitive': 1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -11543,7 +11510,7 @@ snapshots:
'@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/primitive': 1.0.1 '@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-primitive': 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -11585,12 +11552,12 @@ snapshots:
'@radix-ui/react-focus-guards@1.0.0(react@19.2.4)': '@radix-ui/react-focus-guards@1.0.0(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
react: 19.2.4 react: 19.2.4
'@radix-ui/react-focus-guards@1.0.1(@types/react@19.2.14)(react@19.2.4)': '@radix-ui/react-focus-guards@1.0.1(@types/react@19.2.14)(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
react: 19.2.4 react: 19.2.4
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
@ -11603,7 +11570,7 @@ snapshots:
'@radix-ui/react-focus-scope@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-focus-scope@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-compose-refs': 1.0.0(react@19.2.4) '@radix-ui/react-compose-refs': 1.0.0(react@19.2.4)
'@radix-ui/react-primitive': 1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.4) '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.4)
@ -11612,7 +11579,7 @@ snapshots:
'@radix-ui/react-focus-scope@1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-focus-scope@1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-primitive': 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@19.2.14)(react@19.2.4)
@ -11666,13 +11633,13 @@ snapshots:
'@radix-ui/react-id@1.0.0(react@19.2.4)': '@radix-ui/react-id@1.0.0(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.4) '@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.4)
react: 19.2.4 react: 19.2.4
'@radix-ui/react-id@1.0.1(@types/react@19.2.14)(react@19.2.4)': '@radix-ui/react-id@1.0.1(@types/react@19.2.14)(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4 react: 19.2.4
optionalDependencies: optionalDependencies:
@ -11848,14 +11815,14 @@ snapshots:
'@radix-ui/react-portal@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-portal@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-primitive': 1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-primitive': 1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
'@radix-ui/react-portal@1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-portal@1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-primitive': 1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
@ -11875,7 +11842,7 @@ snapshots:
'@radix-ui/react-presence@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-presence@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-compose-refs': 1.0.0(react@19.2.4) '@radix-ui/react-compose-refs': 1.0.0(react@19.2.4)
'@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.4) '@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.4)
react: 19.2.4 react: 19.2.4
@ -11883,7 +11850,7 @@ snapshots:
'@radix-ui/react-presence@1.0.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-presence@1.0.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4)
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4 react: 19.2.4
@ -11904,14 +11871,14 @@ snapshots:
'@radix-ui/react-primitive@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-primitive@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-slot': 1.0.0(react@19.2.4) '@radix-ui/react-slot': 1.0.0(react@19.2.4)
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
'@radix-ui/react-primitive@1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@radix-ui/react-primitive@1.0.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-slot': 1.0.2(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-slot': 1.0.2(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
@ -12067,13 +12034,13 @@ snapshots:
'@radix-ui/react-slot@1.0.0(react@19.2.4)': '@radix-ui/react-slot@1.0.0(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-compose-refs': 1.0.0(react@19.2.4) '@radix-ui/react-compose-refs': 1.0.0(react@19.2.4)
react: 19.2.4 react: 19.2.4
'@radix-ui/react-slot@1.0.2(@types/react@19.2.14)(react@19.2.4)': '@radix-ui/react-slot@1.0.2(@types/react@19.2.14)(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-compose-refs': 1.0.1(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4 react: 19.2.4
optionalDependencies: optionalDependencies:
@ -12207,12 +12174,12 @@ snapshots:
'@radix-ui/react-use-callback-ref@1.0.0(react@19.2.4)': '@radix-ui/react-use-callback-ref@1.0.0(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
react: 19.2.4 react: 19.2.4
'@radix-ui/react-use-callback-ref@1.0.1(@types/react@19.2.14)(react@19.2.4)': '@radix-ui/react-use-callback-ref@1.0.1(@types/react@19.2.14)(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
react: 19.2.4 react: 19.2.4
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
@ -12225,13 +12192,13 @@ snapshots:
'@radix-ui/react-use-controllable-state@1.0.0(react@19.2.4)': '@radix-ui/react-use-controllable-state@1.0.0(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.4) '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.4)
react: 19.2.4 react: 19.2.4
'@radix-ui/react-use-controllable-state@1.0.1(@types/react@19.2.14)(react@19.2.4)': '@radix-ui/react-use-controllable-state@1.0.1(@types/react@19.2.14)(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4 react: 19.2.4
optionalDependencies: optionalDependencies:
@ -12254,13 +12221,13 @@ snapshots:
'@radix-ui/react-use-escape-keydown@1.0.0(react@19.2.4)': '@radix-ui/react-use-escape-keydown@1.0.0(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.4) '@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.4)
react: 19.2.4 react: 19.2.4
'@radix-ui/react-use-escape-keydown@1.0.3(@types/react@19.2.14)(react@19.2.4)': '@radix-ui/react-use-escape-keydown@1.0.3(@types/react@19.2.14)(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@19.2.14)(react@19.2.4) '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@19.2.14)(react@19.2.4)
react: 19.2.4 react: 19.2.4
optionalDependencies: optionalDependencies:
@ -12282,12 +12249,12 @@ snapshots:
'@radix-ui/react-use-layout-effect@1.0.0(react@19.2.4)': '@radix-ui/react-use-layout-effect@1.0.0(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
react: 19.2.4 react: 19.2.4
'@radix-ui/react-use-layout-effect@1.0.1(@types/react@19.2.14)(react@19.2.4)': '@radix-ui/react-use-layout-effect@1.0.1(@types/react@19.2.14)(react@19.2.4)':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
react: 19.2.4 react: 19.2.4
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
@ -12389,7 +12356,7 @@ snapshots:
'@opentelemetry/api-logs': 0.203.0 '@opentelemetry/api-logs': 0.203.0
'@opentelemetry/auto-instrumentations-node': 0.62.2(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)) '@opentelemetry/auto-instrumentations-node': 0.62.2(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))
'@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-node': 0.203.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': 0.203.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-trace-node': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-node': 2.6.0(@opentelemetry/api@1.9.0)
@ -12417,7 +12384,7 @@ snapshots:
js-xxhash: 4.0.0 js-xxhash: 4.0.0
json-custom-numbers: 3.1.1 json-custom-numbers: 3.1.1
kasi: 1.1.2 kasi: 1.1.2
nanoid: 5.1.6 nanoid: 5.1.7
parse-prometheus-text-format: 1.1.1 parse-prometheus-text-format: 1.1.1
pg-format: pg-format-fix@1.0.5 pg-format: pg-format-fix@1.0.5
postgres: 3.4.7 postgres: 3.4.7
@ -12756,10 +12723,10 @@ snapshots:
'@tabler/icons-react@3.37.1(react@19.2.4)': '@tabler/icons-react@3.37.1(react@19.2.4)':
dependencies: dependencies:
'@tabler/icons': 3.37.1 '@tabler/icons': 3.41.1
react: 19.2.4 react: 19.2.4
'@tabler/icons@3.37.1': {} '@tabler/icons@3.41.1': {}
'@tailwindcss/node@4.2.1': '@tailwindcss/node@4.2.1':
dependencies: dependencies:
@ -15875,8 +15842,6 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
nanoid@5.1.6: {}
nanoid@5.1.7: {} nanoid@5.1.7: {}
napi-build-utils@2.0.0: {} napi-build-utils@2.0.0: {}
@ -16256,7 +16221,7 @@ snapshots:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0 '@opentelemetry/api-logs': 0.208.0
'@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.0)
'@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0)
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.0)
'@posthog/core': 1.23.1 '@posthog/core': 1.23.1
'@posthog/types': 1.352.1 '@posthog/types': 1.352.1
@ -16323,7 +16288,7 @@ snapshots:
property-information@7.1.0: {} property-information@7.1.0: {}
protobufjs@7.5.4: protobufjs@7.5.5:
dependencies: dependencies:
'@protobufjs/aspromise': 1.1.2 '@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2 '@protobufjs/base64': 1.1.2
@ -16560,7 +16525,7 @@ snapshots:
react-syntax-highlighter@15.6.6(react@19.2.4): react-syntax-highlighter@15.6.6(react@19.2.4):
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
highlight.js: 10.7.3 highlight.js: 10.7.3
highlightjs-vue: 1.0.0 highlightjs-vue: 1.0.0
lowlight: 1.20.0 lowlight: 1.20.0
@ -16645,7 +16610,7 @@ snapshots:
redux@4.2.1: redux@4.2.1:
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.29.2
reflect.getprototypeof@1.0.10: reflect.getprototypeof@1.0.10:
dependencies: dependencies:

View file

@ -102,9 +102,29 @@ interface ElectronAPI {
setShortcuts: ( setShortcuts: (
config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }> config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }>
) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>; ) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>;
// Launch on system startup
getAutoLaunch: () => Promise<{
enabled: boolean;
openAsHidden: boolean;
supported: boolean;
}>;
setAutoLaunch: (
enabled: boolean,
openAsHidden?: boolean
) => Promise<{ enabled: boolean; openAsHidden: boolean; supported: boolean }>;
// Active search space // Active search space
getActiveSearchSpace: () => Promise<string | null>; getActiveSearchSpace: () => Promise<string | null>;
setActiveSearchSpace: (id: string) => Promise<void>; setActiveSearchSpace: (id: string) => Promise<void>;
// Analytics bridge (PostHog mirror into the Electron main process)
analyticsIdentify: (userId: string, properties?: Record<string, unknown>) => Promise<void>;
analyticsReset: () => Promise<void>;
analyticsCapture: (event: string, properties?: Record<string, unknown>) => Promise<void>;
getAnalyticsContext: () => Promise<{
distinctId: string;
machineId: string;
appVersion: string;
platform: string;
}>;
} }
declare global { declare global {