mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 10:26:33 +02:00
Merge upstream/dev into feature/mcp-migration
This commit is contained in:
commit
4915675f45
54 changed files with 2050 additions and 359 deletions
43
.github/workflows/desktop-release.yml
vendored
43
.github/workflows/desktop-release.yml
vendored
|
|
@ -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 || '' }}
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.0.16
|
0.0.19
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"):
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
4
surfsense_backend/uv.lock
generated
4
surfsense_backend/uv.lock
generated
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
304
surfsense_desktop/src/modules/auto-launch.ts
Normal file
304
surfsense_desktop/src/modules/auto-launch.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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(() => {});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
surfsense_web/content/docs/how-to/ollama.mdx
Normal file
90
surfsense_web/content/docs/how-to/ollama.mdx
Normal 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)
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
183
surfsense_web/pnpm-lock.yaml
generated
183
surfsense_web/pnpm-lock.yaml
generated
|
|
@ -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:
|
||||||
|
|
|
||||||
20
surfsense_web/types/window.d.ts
vendored
20
surfsense_web/types/window.d.ts
vendored
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue