mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-26 23:51:14 +02:00
Merge remote-tracking branch 'upstream/dev' into fixes-playground-ui
# Conflicts: # surfsense_web/lib/apis/base-api.service.ts
This commit is contained in:
commit
06c7e27c72
87 changed files with 2882 additions and 1673 deletions
|
|
@ -392,6 +392,15 @@ STT_SERVICE=local/base
|
|||
# OTEL_HTTP_PORT=4318
|
||||
# OTEL_HEALTH_PORT=13133
|
||||
|
||||
# PostHog product analytics (server-side). Opt-in like OTel: leave
|
||||
# POSTHOG_API_KEY unset for zero telemetry. Passed to backend/worker/beat via
|
||||
# env_file, so no compose changes are needed. Use the SAME project key as the
|
||||
# frontend's NEXT_PUBLIC_POSTHOG_KEY so server events merge onto the persons the
|
||||
# web app already identifies by user id.
|
||||
# POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# POSTHOG_HOST=https://us.i.posthog.com
|
||||
# POSTHOG_AI_PRIVACY_MODE=true # false ships LLM prompt/completion bodies to PostHog
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Advanced (optional)
|
||||
# ------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -555,6 +555,14 @@ LANGSMITH_PROJECT=surfsense
|
|||
# OTEL_METRIC_EXPORT_INTERVAL=300000 # ms; 5 minutes
|
||||
# OTEL_SDK_DISABLED=true # emergency kill-switch
|
||||
|
||||
# Observability - PostHog product analytics (server-side)
|
||||
# Opt-in like OTel: leave POSTHOG_API_KEY unset for zero telemetry. Use the
|
||||
# SAME project key as the frontend's NEXT_PUBLIC_POSTHOG_KEY so server events
|
||||
# merge onto the persons the web app already identifies by user id.
|
||||
# POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# POSTHOG_HOST=https://us.i.posthog.com
|
||||
# POSTHOG_AI_PRIVACY_MODE=true # false ships LLM prompt/completion bodies to PostHog
|
||||
|
||||
# Skills + subagents
|
||||
# SURFSENSE_ENABLE_SKILLS=false
|
||||
# SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=false
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ from app.gateway.inbox_worker import (
|
|||
start_gateway_inbox_worker,
|
||||
stop_gateway_inbox_worker,
|
||||
)
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.observability import metrics as ot_metrics
|
||||
from app.observability.bootstrap import init_otel, shutdown_otel
|
||||
from app.rate_limiter import get_real_client_ip, limiter
|
||||
|
|
@ -714,6 +715,7 @@ async def lifespan(app: FastAPI):
|
|||
await stop_gateway_inbox_worker()
|
||||
_stop_openrouter_background_refresh()
|
||||
await close_checkpointer()
|
||||
ph_analytics.shutdown()
|
||||
shutdown_otel()
|
||||
|
||||
|
||||
|
|
@ -820,6 +822,56 @@ class RequestPerfMiddleware(BaseHTTPMiddleware):
|
|||
|
||||
app.add_middleware(RequestPerfMiddleware)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PAT / MCP API attribution middleware
|
||||
# ---------------------------------------------------------------------------
|
||||
# Emits a PostHog ``pat_api_request`` event for any request authenticated by a
|
||||
# Personal Access Token, so "documents added via MCP", "searches via MCP" etc.
|
||||
# are queryable without instrumenting each route. Relies on ``get_auth_context``
|
||||
# stashing the resolved principal on ``request.state.auth_context``; requests
|
||||
# that never resolve a PAT principal are silently skipped. No-op when PostHog
|
||||
# is unconfigured.
|
||||
|
||||
|
||||
class PatApiAnalyticsMiddleware(BaseHTTPMiddleware):
|
||||
"""Capture PAT-authenticated API usage (incl. MCP) after each response."""
|
||||
|
||||
async def dispatch(
|
||||
self, request: StarletteRequest, call_next: RequestResponseEndpoint
|
||||
) -> StarletteResponse:
|
||||
response = await call_next(request)
|
||||
with contextlib.suppress(Exception):
|
||||
ctx = getattr(request.state, "auth_context", None)
|
||||
if (
|
||||
ctx is not None
|
||||
and ctx.method == "pat"
|
||||
and ph_analytics.is_enabled()
|
||||
):
|
||||
# Use the route *template* (e.g. /documents/{id}) to keep the
|
||||
# ``route`` property low-cardinality; fall back to the raw path.
|
||||
route = request.scope.get("route")
|
||||
route_path = getattr(route, "path", None) or request.url.path
|
||||
client = (
|
||||
"mcp"
|
||||
if request.headers.get("X-SurfSense-Client") == "mcp"
|
||||
else "pat_script"
|
||||
)
|
||||
ph_analytics.capture_for(
|
||||
ctx,
|
||||
"pat_api_request",
|
||||
{
|
||||
"route": route_path,
|
||||
"method": request.method,
|
||||
"status_code": response.status_code,
|
||||
"client": client,
|
||||
},
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
app.add_middleware(PatApiAnalyticsMiddleware)
|
||||
|
||||
# Add SlowAPI middleware for automatic rate limiting
|
||||
# Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid
|
||||
# corrupting StreamingResponse — SlowAPIASGIMiddleware re-sends
|
||||
|
|
|
|||
|
|
@ -15,11 +15,38 @@ from app.automations.schemas.definition.envelope import (
|
|||
)
|
||||
from app.automations.schemas.definition.plan_step import PlanStep
|
||||
from app.automations.templating import build_run_context
|
||||
from app.observability import analytics as ph_analytics
|
||||
|
||||
from . import repository
|
||||
from .step import execute_step
|
||||
|
||||
|
||||
def _capture_run_outcome(run: AutomationRun, status: str) -> None:
|
||||
"""Emit ``automation_run_completed`` — headless runs the frontend never sees.
|
||||
|
||||
No-op when PostHog is unconfigured or the owning user is unknown.
|
||||
"""
|
||||
automation = run.automation
|
||||
creator_id = getattr(automation, "created_by_user_id", None)
|
||||
if not creator_id:
|
||||
return
|
||||
workspace_id = getattr(automation, "workspace_id", None)
|
||||
ph_analytics.capture(
|
||||
"automation_run_completed",
|
||||
distinct_id=str(creator_id),
|
||||
properties={
|
||||
"automation_id": run.automation_id,
|
||||
"run_id": run.id,
|
||||
"workspace_id": workspace_id,
|
||||
"status": status,
|
||||
"trigger_type": run.trigger.type.value if run.trigger else None,
|
||||
},
|
||||
groups={"workspace": str(workspace_id)}
|
||||
if workspace_id is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
async def execute_run(session: AsyncSession, run_id: int) -> None:
|
||||
"""Load run ``run_id`` and execute its snapshot plan to a terminal state."""
|
||||
run = await repository.load_run(session, run_id)
|
||||
|
|
@ -41,6 +68,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
|
|||
},
|
||||
)
|
||||
await session.commit()
|
||||
_capture_run_outcome(run, "failed")
|
||||
return
|
||||
|
||||
await repository.mark_running(session, run)
|
||||
|
|
@ -66,6 +94,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
|
|||
await _run_on_failure(session, run, definition)
|
||||
await repository.mark_failed(session, run, result.get("error"))
|
||||
await session.commit()
|
||||
_capture_run_outcome(run, "failed")
|
||||
return
|
||||
|
||||
if result["status"] == "succeeded":
|
||||
|
|
@ -73,6 +102,7 @@ async def execute_run(session: AsyncSession, run_id: int) -> None:
|
|||
|
||||
await repository.mark_succeeded(session, run)
|
||||
await session.commit()
|
||||
_capture_run_outcome(run, "succeeded")
|
||||
|
||||
|
||||
async def _run_on_failure(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from app.automations.services.model_policy import (
|
|||
from app.automations.triggers import get_trigger
|
||||
from app.automations.triggers.builtin.schedule import compute_next_fire_at
|
||||
from app.db import Permission, Workspace, get_async_session
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.users import get_auth_context
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
|
|
@ -75,6 +76,18 @@ class AutomationService:
|
|||
|
||||
self.session.add(automation)
|
||||
await self.session.commit()
|
||||
|
||||
# Authoritative creation (migrated from automations-mutation.atoms.ts).
|
||||
ph_analytics.capture_for(
|
||||
self.auth,
|
||||
"automation_created",
|
||||
{
|
||||
"automation_id": automation.id,
|
||||
"workspace_id": automation.workspace_id,
|
||||
"trigger_count": len(payload.triggers),
|
||||
},
|
||||
groups={"workspace": str(automation.workspace_id)},
|
||||
)
|
||||
return await self._get_with_triggers_or_raise(automation.id)
|
||||
|
||||
async def list(
|
||||
|
|
@ -150,6 +163,31 @@ class AutomationService:
|
|||
automation.version += 1
|
||||
|
||||
await self.session.commit()
|
||||
|
||||
# Migrated from automations-mutation.atoms.ts: a status-only change is a
|
||||
# distinct event; other field edits are ``automation_updated``.
|
||||
if "status" in data:
|
||||
ph_analytics.capture_for(
|
||||
self.auth,
|
||||
"automation_status_changed",
|
||||
{
|
||||
"automation_id": automation.id,
|
||||
"workspace_id": automation.workspace_id,
|
||||
"next_status": str(data["status"]),
|
||||
},
|
||||
groups={"workspace": str(automation.workspace_id)},
|
||||
)
|
||||
if any(k in data for k in ("name", "description", "definition")):
|
||||
ph_analytics.capture_for(
|
||||
self.auth,
|
||||
"automation_updated",
|
||||
{
|
||||
"automation_id": automation.id,
|
||||
"workspace_id": automation.workspace_id,
|
||||
"has_definition_change": "definition" in data,
|
||||
},
|
||||
groups={"workspace": str(automation.workspace_id)},
|
||||
)
|
||||
return await self._get_with_triggers_or_raise(automation_id)
|
||||
|
||||
async def delete(self, automation_id: int) -> None:
|
||||
|
|
@ -158,9 +196,18 @@ class AutomationService:
|
|||
await self._authorize(
|
||||
automation.workspace_id, Permission.AUTOMATIONS_DELETE.value
|
||||
)
|
||||
workspace_id = automation.workspace_id
|
||||
await self.session.delete(automation)
|
||||
await self.session.commit()
|
||||
|
||||
# Authoritative deletion (migrated from automations-mutation.atoms.ts).
|
||||
ph_analytics.capture_for(
|
||||
self.auth,
|
||||
"automation_deleted",
|
||||
{"automation_id": automation_id, "workspace_id": workspace_id},
|
||||
groups={"workspace": str(workspace_id)},
|
||||
)
|
||||
|
||||
async def _get_or_raise(self, automation_id: int) -> Automation:
|
||||
automation = await self.session.get(Automation, automation_id)
|
||||
if automation is None:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from app.automations.schemas.api import TriggerCreate, TriggerUpdate
|
|||
from app.automations.triggers import get_trigger
|
||||
from app.automations.triggers.builtin.schedule import compute_next_fire_at
|
||||
from app.db import Permission, get_async_session
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.users import get_auth_context
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
|
|
@ -48,6 +49,18 @@ class TriggerService:
|
|||
self.session.add(trigger)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(trigger)
|
||||
|
||||
# Migrated from automations-mutation.atoms.ts.
|
||||
ph_analytics.capture_for(
|
||||
self.auth,
|
||||
"automation_trigger_added",
|
||||
{
|
||||
"automation_id": automation_id,
|
||||
"trigger_id": trigger.id,
|
||||
"trigger_type": getattr(trigger.type, "value", str(trigger.type)),
|
||||
"enabled": trigger.enabled,
|
||||
},
|
||||
)
|
||||
return trigger
|
||||
|
||||
async def update(
|
||||
|
|
@ -82,6 +95,26 @@ class TriggerService:
|
|||
|
||||
await self.session.commit()
|
||||
await self.session.refresh(trigger)
|
||||
|
||||
# Migrated from automations-mutation.atoms.ts. ``change`` mirrors the
|
||||
# frontend's coarse categorisation.
|
||||
_change = (
|
||||
"enabled"
|
||||
if "enabled" in data and "params" not in data
|
||||
else "params"
|
||||
if "params" in data
|
||||
else "other"
|
||||
)
|
||||
ph_analytics.capture_for(
|
||||
self.auth,
|
||||
"automation_trigger_updated",
|
||||
{
|
||||
"automation_id": automation_id,
|
||||
"trigger_id": trigger_id,
|
||||
"change": _change,
|
||||
"enabled": trigger.enabled,
|
||||
},
|
||||
)
|
||||
return trigger
|
||||
|
||||
async def remove(self, *, automation_id: int, trigger_id: int) -> None:
|
||||
|
|
@ -92,6 +125,13 @@ class TriggerService:
|
|||
await self.session.delete(trigger)
|
||||
await self.session.commit()
|
||||
|
||||
# Migrated from automations-mutation.atoms.ts.
|
||||
ph_analytics.capture_for(
|
||||
self.auth,
|
||||
"automation_trigger_removed",
|
||||
{"automation_id": automation_id, "trigger_id": trigger_id},
|
||||
)
|
||||
|
||||
async def _authorize_automation(
|
||||
self, automation_id: int, permission: str
|
||||
) -> Automation:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Scraper capability registry — typed, stateless verbs. See plans/backend/04-capabilities.md."""
|
||||
"""Scraper capability registry — typed, stateless verbs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ from app.capabilities.core.store import all_capabilities
|
|||
from app.capabilities.core.types import Capability, CapabilityContext
|
||||
from app.db import Run, async_session_maker, get_async_session
|
||||
from app.exceptions import ExternalServiceError, SurfSenseError
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.services.web_crawl_credit_service import InsufficientCreditsError
|
||||
from app.users import get_auth_context
|
||||
from app.utils.rbac import check_workspace_access
|
||||
|
|
@ -107,6 +108,41 @@ def _origin_for(auth: AuthContext) -> str:
|
|||
return "ui" if getattr(auth, "method", None) == "session" else "api"
|
||||
|
||||
|
||||
def _capture_scraper_run(
|
||||
*,
|
||||
capability: str,
|
||||
status: str,
|
||||
user_id,
|
||||
origin: str,
|
||||
duration_ms: int | None = None,
|
||||
item_count: int | None = None,
|
||||
cost_micros: int | None = None,
|
||||
) -> None:
|
||||
"""Emit ``scraper_run_completed`` — scrapers are the highest-value MCP surface.
|
||||
|
||||
Covers both sync and async runs; the async background task never flows
|
||||
through the PAT middleware, so this is the only place its outcome is
|
||||
captured. No-op when PostHog is unconfigured.
|
||||
"""
|
||||
if not ph_analytics.is_enabled() or not user_id:
|
||||
return
|
||||
platform, _, verb = capability.partition(".")
|
||||
ph_analytics.capture(
|
||||
"scraper_run_completed",
|
||||
distinct_id=str(user_id),
|
||||
properties={
|
||||
"platform": platform,
|
||||
"verb": verb,
|
||||
"capability": capability,
|
||||
"status": status,
|
||||
"origin": origin,
|
||||
"duration_ms": duration_ms,
|
||||
"item_count": item_count,
|
||||
"cost_micros": cost_micros,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
|
@ -185,6 +221,8 @@ async def _execute_async_run(
|
|||
unit,
|
||||
executor,
|
||||
payload,
|
||||
user_id=None,
|
||||
origin: str = "api",
|
||||
) -> None:
|
||||
"""Run a scrape in the background: stream progress, charge, finalize the row.
|
||||
|
||||
|
|
@ -218,6 +256,13 @@ async def _execute_async_run(
|
|||
progress=reporter.coarse,
|
||||
)
|
||||
_publish_finished(run_id, "error", error=str(exc))
|
||||
_capture_scraper_run(
|
||||
capability=capability,
|
||||
status="error",
|
||||
user_id=user_id,
|
||||
origin=origin,
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("async run %s failed with an upstream error", run_id)
|
||||
|
|
@ -229,6 +274,13 @@ async def _execute_async_run(
|
|||
progress=reporter.coarse,
|
||||
)
|
||||
_publish_finished(run_id, "error", error="upstream error")
|
||||
_capture_scraper_run(
|
||||
capability=capability,
|
||||
status="error",
|
||||
user_id=user_id,
|
||||
origin=origin,
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
)
|
||||
return
|
||||
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
|
|
@ -252,6 +304,15 @@ async def _execute_async_run(
|
|||
progress=reporter.coarse,
|
||||
)
|
||||
_publish_finished(run_id, "success", item_count=serialized.item_count)
|
||||
_capture_scraper_run(
|
||||
capability=capability,
|
||||
status="success",
|
||||
user_id=user_id,
|
||||
origin=origin,
|
||||
duration_ms=duration_ms,
|
||||
item_count=serialized.item_count,
|
||||
cost_micros=cost_micros,
|
||||
)
|
||||
|
||||
|
||||
async def _finalize_async(
|
||||
|
|
@ -358,6 +419,8 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
|||
unit=unit,
|
||||
executor=executor,
|
||||
payload=payload,
|
||||
user_id=user_id,
|
||||
origin=origin,
|
||||
)
|
||||
)
|
||||
run_event_bus.register_task(run_id, task)
|
||||
|
|
@ -373,6 +436,7 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
|||
try:
|
||||
output = await executor(payload)
|
||||
except (SurfSenseError, HTTPException) as exc:
|
||||
_sync_err_duration = int((time.perf_counter() - started) * 1000)
|
||||
await _record_rest_run(
|
||||
workspace_id=workspace_id,
|
||||
capability=name,
|
||||
|
|
@ -381,11 +445,19 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
|||
input=input_dump,
|
||||
user_id=user_id,
|
||||
error=str(exc),
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
duration_ms=_sync_err_duration,
|
||||
progress=reporter.coarse,
|
||||
)
|
||||
_capture_scraper_run(
|
||||
capability=name,
|
||||
status="error",
|
||||
user_id=user_id,
|
||||
origin=origin,
|
||||
duration_ms=_sync_err_duration,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
_sync_err_duration = int((time.perf_counter() - started) * 1000)
|
||||
await _record_rest_run(
|
||||
workspace_id=workspace_id,
|
||||
capability=name,
|
||||
|
|
@ -394,9 +466,16 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
|||
input=input_dump,
|
||||
user_id=user_id,
|
||||
error=str(exc),
|
||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
||||
duration_ms=_sync_err_duration,
|
||||
progress=reporter.coarse,
|
||||
)
|
||||
_capture_scraper_run(
|
||||
capability=name,
|
||||
status="error",
|
||||
user_id=user_id,
|
||||
origin=origin,
|
||||
duration_ms=_sync_err_duration,
|
||||
)
|
||||
raise ExternalServiceError(
|
||||
f"The '{name}' capability failed due to an upstream error.",
|
||||
code="CAPABILITY_UPSTREAM_ERROR",
|
||||
|
|
@ -418,6 +497,15 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
|||
cost_micros=cost_micros,
|
||||
progress=reporter.coarse,
|
||||
)
|
||||
_capture_scraper_run(
|
||||
capability=name,
|
||||
status="success",
|
||||
user_id=user_id,
|
||||
origin=origin,
|
||||
duration_ms=duration_ms,
|
||||
item_count=serialized.item_count,
|
||||
cost_micros=cost_micros,
|
||||
)
|
||||
if run_id is not None:
|
||||
response.headers["X-Run-Id"] = f"run_{run_id}"
|
||||
return output
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from celery.signals import (
|
|||
task_postrun,
|
||||
task_prerun,
|
||||
worker_process_init,
|
||||
worker_process_shutdown,
|
||||
)
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
|
@ -123,6 +124,18 @@ def init_worker(**kwargs):
|
|||
initialize_image_gen_router()
|
||||
|
||||
|
||||
@worker_process_shutdown.connect
|
||||
def shutdown_worker(**kwargs):
|
||||
"""Flush queued PostHog events before a Celery worker process exits.
|
||||
|
||||
The analytics client init is lazy (fork-safe), so there is nothing to
|
||||
start here — only a flush to avoid dropping events captured by tasks.
|
||||
"""
|
||||
from app.observability import analytics as ph_analytics
|
||||
|
||||
ph_analytics.shutdown()
|
||||
|
||||
|
||||
# Celery configuration, sourced from the central Config singleton
|
||||
CELERY_BROKER_URL = config.CELERY_BROKER_URL
|
||||
CELERY_RESULT_BACKEND = config.CELERY_RESULT_BACKEND
|
||||
|
|
|
|||
|
|
@ -1179,6 +1179,19 @@ class Config:
|
|||
os.getenv("CRAWL_HEADED_XVFB_ENABLED", "FALSE").upper() == "TRUE"
|
||||
)
|
||||
|
||||
# PostHog server-side product analytics (opt-in, mirrors the OTel pattern:
|
||||
# no key set => the analytics wrapper is a silent no-op). Use the SAME
|
||||
# project key as the frontend's NEXT_PUBLIC_POSTHOG_KEY so server events
|
||||
# merge onto the persons the web app already identifies by user id.
|
||||
POSTHOG_API_KEY = os.getenv("POSTHOG_API_KEY")
|
||||
POSTHOG_HOST = os.getenv("POSTHOG_HOST", "https://us.i.posthog.com")
|
||||
# When true (default), the LLM-analytics LangChain handler suppresses
|
||||
# prompt/completion bodies ($ai_input / $ai_output_choices) and captures
|
||||
# only metrics — chat content includes users' private documents.
|
||||
POSTHOG_AI_PRIVACY_MODE = (
|
||||
os.getenv("POSTHOG_AI_PRIVACY_MODE", "TRUE").upper() == "TRUE"
|
||||
)
|
||||
|
||||
# Litellm TTS Configuration
|
||||
TTS_SERVICE = os.getenv("TTS_SERVICE")
|
||||
TTS_SERVICE_API_BASE = os.getenv("TTS_SERVICE_API_BASE")
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ wrapper is a no-op when OTEL is not configured, so importing it from
|
|||
performance-critical paths is safe.
|
||||
"""
|
||||
|
||||
__all__ = ["bootstrap", "metrics", "otel"]
|
||||
__all__ = ["analytics", "bootstrap", "metrics", "otel"]
|
||||
|
|
|
|||
202
surfsense_backend/app/observability/analytics.py
Normal file
202
surfsense_backend/app/observability/analytics.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"""Server-side PostHog product analytics for SurfSense.
|
||||
|
||||
Opt-in, mirroring the OpenTelemetry bootstrap contract: when
|
||||
``POSTHOG_API_KEY`` is unset every function here is a silent no-op, so it is
|
||||
safe to call from hot paths (including async request handlers) and from
|
||||
self-hosted installs that never configure telemetry.
|
||||
|
||||
Design notes:
|
||||
- The underlying ``posthog`` client enqueues events onto a background
|
||||
consumer thread, so ``capture()`` is a non-blocking queue append; the only
|
||||
network I/O happens off-thread. ``shutdown()`` flushes and joins that thread
|
||||
and MUST run before a process exits or queued events are lost.
|
||||
- The client is created lazily on first use, never at import time. This keeps
|
||||
it fork-safe under Celery's prefork pool: a client (and its consumer thread)
|
||||
created in the parent would not survive ``fork()``, so each worker process
|
||||
builds its own on first capture.
|
||||
- ``distinct_id`` is always ``str(user.id)`` so server events merge onto the
|
||||
same PostHog persons the web frontend identifies (see
|
||||
``surfsense_web/components/providers/PostHogIdentify.tsx``).
|
||||
- Every event passes ``disable_geoip=True``; without it PostHog would resolve
|
||||
the *server's* IP and overwrite each person's real (client-derived) location.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.config import config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.auth.context import AuthContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: Any | None = None
|
||||
_init_attempted = False
|
||||
_lock = threading.Lock()
|
||||
|
||||
# Stamped on every backend event so client-observed (frontend) and
|
||||
# server-truth events are always distinguishable in PostHog.
|
||||
_SOURCE = "backend"
|
||||
|
||||
|
||||
def _get_client() -> Any | None:
|
||||
"""Return the process-local PostHog client, or ``None`` when disabled.
|
||||
|
||||
Lazy + fork-safe: built on first use inside whichever process (web worker
|
||||
or Celery worker) calls it, never at import time.
|
||||
"""
|
||||
global _client, _init_attempted
|
||||
|
||||
if _init_attempted:
|
||||
return _client
|
||||
|
||||
with _lock:
|
||||
if _init_attempted:
|
||||
return _client
|
||||
_init_attempted = True
|
||||
|
||||
api_key = config.POSTHOG_API_KEY
|
||||
if not api_key:
|
||||
# ponytail: opt-in like OTel — no key means telemetry is off, not
|
||||
# a misconfiguration. Stay silent so self-hosters see no noise.
|
||||
return None
|
||||
|
||||
try:
|
||||
from posthog import Posthog
|
||||
|
||||
_client = Posthog(
|
||||
project_api_key=api_key,
|
||||
host=config.POSTHOG_HOST,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("PostHog analytics init failed; disabling", exc_info=True)
|
||||
_client = None
|
||||
|
||||
return _client
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""True when a PostHog client is configured and available."""
|
||||
return _get_client() is not None
|
||||
|
||||
|
||||
def get_client() -> Any | None:
|
||||
"""Raw PostHog client for integrations that need it (e.g. the LLM handler)."""
|
||||
return _get_client()
|
||||
|
||||
|
||||
def _client_label(auth: AuthContext) -> str:
|
||||
"""Best-effort ``client`` property derived from the auth principal.
|
||||
|
||||
``session`` can't be split into web vs desktop from auth alone, so callers
|
||||
that know better may override ``client`` in ``properties``.
|
||||
"""
|
||||
if auth.method == "system":
|
||||
return auth.source or "system"
|
||||
if auth.method == "pat":
|
||||
return "pat"
|
||||
return "web"
|
||||
|
||||
|
||||
def capture(
|
||||
event: str,
|
||||
*,
|
||||
distinct_id: str,
|
||||
properties: dict[str, Any] | None = None,
|
||||
groups: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Capture a product event. No-op (and never raises) when disabled.
|
||||
|
||||
Wrapped in try/except like the frontend ``safeCapture`` — analytics must
|
||||
never break a request. ``posthog`` v6 signature is ``capture(event,
|
||||
distinct_id=..., properties=...)`` (event first, distinct_id a kwarg).
|
||||
"""
|
||||
client = _get_client()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
props = {"source": _SOURCE, **(properties or {})}
|
||||
client.capture(
|
||||
event,
|
||||
distinct_id=distinct_id,
|
||||
properties=props,
|
||||
groups=groups,
|
||||
disable_geoip=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("PostHog capture failed for %s", event, exc_info=True)
|
||||
|
||||
|
||||
def capture_for(
|
||||
auth: AuthContext,
|
||||
event: str,
|
||||
properties: dict[str, Any] | None = None,
|
||||
groups: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Capture an event attributed to an ``AuthContext`` principal.
|
||||
|
||||
Derives ``distinct_id`` from the user id and stamps ``auth_method`` and a
|
||||
best-effort ``client`` so events are attributable to their surface
|
||||
(web/desktop/pat/gateway/automation).
|
||||
"""
|
||||
if _get_client() is None:
|
||||
return
|
||||
|
||||
props = {
|
||||
"auth_method": auth.method,
|
||||
"client": _client_label(auth),
|
||||
**(properties or {}),
|
||||
}
|
||||
capture(
|
||||
event,
|
||||
distinct_id=str(auth.user.id),
|
||||
properties=props,
|
||||
groups=groups,
|
||||
)
|
||||
|
||||
|
||||
def group_identify(
|
||||
group_type: str,
|
||||
group_key: str,
|
||||
properties: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Upsert group properties (e.g. per-workspace metadata). No-op when disabled."""
|
||||
client = _get_client()
|
||||
if client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
client.group_identify(
|
||||
group_type=group_type,
|
||||
group_key=group_key,
|
||||
properties=properties or {},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("PostHog group_identify failed for %s", group_type, exc_info=True)
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Flush queued events and stop the consumer thread. Safe to call always."""
|
||||
global _client
|
||||
client = _client
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.shutdown()
|
||||
except Exception:
|
||||
logger.debug("PostHog shutdown failed", exc_info=True)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"capture",
|
||||
"capture_for",
|
||||
"get_client",
|
||||
"group_identify",
|
||||
"is_enabled",
|
||||
"shutdown",
|
||||
]
|
||||
|
|
@ -11,7 +11,10 @@ import logging
|
|||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.celery_app import celery_app
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.podcasts.persistence import PodcastRepository
|
||||
from app.podcasts.rendering import PodcastRenderer
|
||||
from app.podcasts.service import (
|
||||
|
|
@ -76,6 +79,30 @@ async def _render_audio(podcast_id: int) -> dict:
|
|||
podcast, storage_backend=backend_name, storage_key=key
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Credit-consuming deliverable; the frontend never confirms the
|
||||
# render finished. Owner (workspace.user_id) resolved lazily so
|
||||
# disabled installs pay nothing for the extra query.
|
||||
if ph_analytics.is_enabled():
|
||||
# Local import: app.db <-> app.podcasts.persistence have a
|
||||
# module-init cycle; deferring keeps this task importable.
|
||||
from app.db import Workspace
|
||||
|
||||
owner_id = await session.scalar(
|
||||
select(Workspace.user_id).where(
|
||||
Workspace.id == podcast.workspace_id
|
||||
)
|
||||
)
|
||||
if owner_id:
|
||||
ph_analytics.capture(
|
||||
"podcast_generated",
|
||||
distinct_id=str(owner_id),
|
||||
properties={
|
||||
"workspace_id": podcast.workspace_id,
|
||||
"podcast_id": podcast_id,
|
||||
},
|
||||
groups={"workspace": str(podcast.workspace_id)},
|
||||
)
|
||||
except InvalidTransitionError:
|
||||
# A user back-out won the race (e.g. the regeneration was
|
||||
# reverted): drop the stale render and leave the row alone.
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
|
|
@ -13,6 +14,10 @@ from fastapi.responses import StreamingResponse
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import config
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.tasks.chat.streaming.flows.shared.analytics import (
|
||||
build_llm_callback_handler,
|
||||
)
|
||||
from app.etl_pipeline.file_classifier import (
|
||||
DIRECT_CONVERT_EXTENSIONS,
|
||||
PLAINTEXT_EXTENSIONS,
|
||||
|
|
@ -354,6 +359,7 @@ async def stream_anonymous_chat(
|
|||
|
||||
accumulator = start_turn()
|
||||
streaming_service = VercelStreamingService()
|
||||
anon_outcome = "success"
|
||||
|
||||
try:
|
||||
async with shielded_async_session():
|
||||
|
|
@ -394,6 +400,21 @@ async def stream_anonymous_chat(
|
|||
"recursion_limit": 40,
|
||||
}
|
||||
|
||||
# PostHog LLM analytics for the free tier — model spend per
|
||||
# model is the highest-value cost insight. distinct_id is the
|
||||
# anon session id (not joined to any registered person).
|
||||
_anon_llm_handler = build_llm_callback_handler(
|
||||
distinct_id=session_id,
|
||||
trace_id=anon_thread_id,
|
||||
properties={
|
||||
"client": "anonymous",
|
||||
"model_slug": body.model_slug,
|
||||
"$ai_session_id": session_id,
|
||||
},
|
||||
)
|
||||
if _anon_llm_handler is not None:
|
||||
langgraph_config["callbacks"] = [_anon_llm_handler]
|
||||
|
||||
yield streaming_service.format_message_start()
|
||||
yield streaming_service.format_start_step()
|
||||
|
||||
|
|
@ -465,6 +486,7 @@ async def stream_anonymous_chat(
|
|||
|
||||
except Exception as e:
|
||||
logger.exception("Anonymous chat stream error")
|
||||
anon_outcome = "error"
|
||||
await TokenQuotaService.anon_release(session_key, ip_key, request_id)
|
||||
_, error_code, _, _, user_message, extra = classify_stream_exception(
|
||||
e,
|
||||
|
|
@ -479,6 +501,26 @@ async def stream_anonymous_chat(
|
|||
finally:
|
||||
await TokenQuotaService.anon_release_stream_slot(client_ip)
|
||||
|
||||
# Server-truth free-tier volume/model/outcome/token-burn. distinct_id
|
||||
# is the anon session id — deliberately NOT joined to the frontend's
|
||||
# PostHog anonymous id (the point is server truth, not funnel merge).
|
||||
with contextlib.suppress(Exception):
|
||||
if ph_analytics.is_enabled():
|
||||
ph_analytics.capture(
|
||||
"anon_chat_turn_completed",
|
||||
distinct_id=session_id,
|
||||
properties={
|
||||
"client": "anonymous",
|
||||
"outcome": anon_outcome,
|
||||
"model_slug": body.model_slug,
|
||||
"model_name": model_cfg.get("model_name"),
|
||||
"total_tokens": accumulator.grand_total,
|
||||
"prompt_tokens": accumulator.total_prompt_tokens,
|
||||
"completion_tokens": accumulator.total_completion_tokens,
|
||||
"cost_micros": accumulator.total_cost_micros,
|
||||
},
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
_generate(),
|
||||
media_type="text/event-stream",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.auth.context import AuthContext
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.config import config
|
||||
from app.db import (
|
||||
ImageGeneration,
|
||||
|
|
@ -327,6 +328,20 @@ async def create_image_generation(
|
|||
|
||||
await session.commit()
|
||||
await session.refresh(db_image_gen)
|
||||
|
||||
# Credit-consuming deliverable; the frontend never confirms
|
||||
# completion. ``error_message`` set => provider call failed.
|
||||
ph_analytics.capture_for(
|
||||
auth,
|
||||
"image_generated",
|
||||
{
|
||||
"workspace_id": data.workspace_id,
|
||||
"model": data.model,
|
||||
"n": data.n,
|
||||
"status": "error" if db_image_gen.error_message else "success",
|
||||
},
|
||||
groups={"workspace": str(data.workspace_id)},
|
||||
)
|
||||
return db_image_gen
|
||||
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from app.db import (
|
|||
UserIncentiveTask,
|
||||
get_async_session,
|
||||
)
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.schemas.incentive_tasks import (
|
||||
CompleteTaskResponse,
|
||||
IncentiveTaskInfo,
|
||||
|
|
@ -125,6 +126,20 @@ async def complete_task(
|
|||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
# Authoritative reward grant (migrated from earn-credits-content.tsx).
|
||||
# Placed after the already-completed early-return so retries never
|
||||
# double-count.
|
||||
ph_analytics.capture_for(
|
||||
auth,
|
||||
"incentive_task_completed",
|
||||
{
|
||||
"task_type": task_type.value
|
||||
if hasattr(task_type, "value")
|
||||
else str(task_type),
|
||||
"credit_micros_rewarded": credit_micros_reward,
|
||||
},
|
||||
)
|
||||
|
||||
return CompleteTaskResponse(
|
||||
success=True,
|
||||
message=f"Task completed! You earned ${credit_micros_reward / 1_000_000:.2f} of credit.",
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ from app.db import (
|
|||
get_async_session,
|
||||
shielded_async_session,
|
||||
)
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.schemas.new_chat import (
|
||||
AgentToolInfo,
|
||||
CancelActiveTurnResponse,
|
||||
|
|
@ -823,6 +824,15 @@ async def create_thread(
|
|||
session.add(db_thread)
|
||||
await session.commit()
|
||||
await session.refresh(db_thread)
|
||||
|
||||
# Authoritative thread creation (migrated from stream-engine/engine.ts).
|
||||
# get_auth_context => covers PAT/MCP callers the frontend never sees.
|
||||
ph_analytics.capture_for(
|
||||
auth,
|
||||
"chat_created",
|
||||
{"workspace_id": db_thread.workspace_id, "chat_id": db_thread.id},
|
||||
groups={"workspace": str(db_thread.workspace_id)},
|
||||
)
|
||||
return db_thread
|
||||
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from sqlalchemy.future import select
|
|||
from app.auth.context import AuthContext
|
||||
from app.config import config
|
||||
from app.db import PersonalAccessToken, get_async_session
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.schemas.pat import PATCreate, PATCreated, PATRead
|
||||
from app.users import require_session_context
|
||||
from app.utils.pat import generate_pat, hash_pat, token_prefix
|
||||
|
|
@ -57,6 +58,13 @@ async def create_personal_access_token(
|
|||
await session.commit()
|
||||
await session.refresh(pat)
|
||||
|
||||
# Leading indicator of MCP / programmatic-API adoption.
|
||||
ph_analytics.capture_for(
|
||||
auth,
|
||||
"pat_created",
|
||||
{"pat_id": pat.id, "has_expiry": pat.expires_at is not None},
|
||||
)
|
||||
|
||||
return PATCreated(
|
||||
id=pat.id,
|
||||
label=pat.label,
|
||||
|
|
@ -102,3 +110,5 @@ async def delete_personal_access_token(
|
|||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
ph_analytics.capture_for(auth, "pat_revoked", {"pat_id": pat_id})
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
||||
from app.auth.context import AuthContext
|
||||
from app.db import get_async_session
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.schemas.new_chat import (
|
||||
CloneResponse,
|
||||
PublicChatResponse,
|
||||
|
|
@ -56,7 +57,20 @@ async def clone_public_chat(
|
|||
Creates thread and copies messages.
|
||||
Requires authentication.
|
||||
"""
|
||||
return await clone_from_snapshot(session, share_token, user)
|
||||
result = await clone_from_snapshot(session, share_token, user)
|
||||
|
||||
# Share-link conversion — only observable server-side.
|
||||
ph_analytics.capture_for(
|
||||
auth,
|
||||
"public_chat_cloned",
|
||||
{
|
||||
"workspace_id": result.workspace_id,
|
||||
"chat_id": result.thread_id,
|
||||
},
|
||||
groups={"workspace": str(result.workspace_id)},
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{share_token}/podcasts/{podcast_id}")
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from app.db import (
|
|||
WorkspaceRole,
|
||||
get_async_session,
|
||||
)
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.schemas import (
|
||||
InviteAcceptRequest,
|
||||
InviteAcceptResponse,
|
||||
|
|
@ -782,6 +783,19 @@ async def create_invite(
|
|||
)
|
||||
db_invite = result.scalars().first()
|
||||
|
||||
# Authoritative invite creation (migrated from team-content.tsx).
|
||||
ph_analytics.capture_for(
|
||||
auth,
|
||||
"workspace_invite_sent",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"role_name": db_invite.role.name if db_invite.role else None,
|
||||
"has_expiry": db_invite.expires_at is not None,
|
||||
"has_max_uses": db_invite.max_uses is not None,
|
||||
},
|
||||
groups={"workspace": str(workspace_id)},
|
||||
)
|
||||
|
||||
return db_invite
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -1091,6 +1105,16 @@ async def accept_invite(
|
|||
role_name = invite.role.name if invite.role else "Default"
|
||||
workspace_name = invite.workspace.name if invite.workspace else ""
|
||||
|
||||
# Authoritative join (migrated from app/invite/[invite_code]/page.tsx,
|
||||
# which fired both events). workspace_name dropped — user content.
|
||||
for _evt in ("workspace_invite_accepted", "workspace_user_added"):
|
||||
ph_analytics.capture_for(
|
||||
auth,
|
||||
_evt,
|
||||
{"workspace_id": invite.workspace_id, "role_name": role_name},
|
||||
groups={"workspace": str(invite.workspace_id)},
|
||||
)
|
||||
|
||||
return InviteAcceptResponse(
|
||||
message="Successfully joined the workspace",
|
||||
workspace_id=invite.workspace_id,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import redis
|
|||
from dateutil.parser import isoparse
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy import event as sa_event
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
|
@ -44,6 +45,7 @@ from app.db import (
|
|||
get_async_session,
|
||||
)
|
||||
from app.notifications.service import NotificationService
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.observability import metrics as ot_metrics, otel as ot
|
||||
from app.schemas import (
|
||||
GoogleDriveIndexRequest,
|
||||
|
|
@ -135,6 +137,45 @@ def _get_heartbeat_key(notification_id: int) -> str:
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def _connector_type_value(connector_type) -> str:
|
||||
"""Low-cardinality connector_type string for analytics (enum -> its value)."""
|
||||
return getattr(connector_type, "value", None) or str(connector_type)
|
||||
|
||||
|
||||
def _emit_connector_connected(_mapper, _connection, target) -> None:
|
||||
"""SQLAlchemy after_insert hook: one guard for every connector-creation path.
|
||||
|
||||
Fires ``connector_connected`` for the form route here AND all 15 OAuth
|
||||
callback routes (each builds ``SearchSourceConnector(...)`` inline with no
|
||||
shared helper), so we don't instrument 16 call sites. ``distinct_id`` comes
|
||||
off the row itself (``user_id``) — the same person the web app identifies.
|
||||
|
||||
ponytail: fires during flush, so a post-flush rollback would over-count
|
||||
(rare; connector creation commits immediately after add). No-op without a
|
||||
PostHog key. Upgrade path: move to an after_commit collector if over-count
|
||||
ever shows up in the data.
|
||||
"""
|
||||
if not ph_analytics.is_enabled():
|
||||
return
|
||||
user_id = getattr(target, "user_id", None)
|
||||
if not user_id:
|
||||
return
|
||||
workspace_id = getattr(target, "workspace_id", None)
|
||||
ph_analytics.capture(
|
||||
"connector_connected",
|
||||
distinct_id=str(user_id),
|
||||
properties={
|
||||
"workspace_id": workspace_id,
|
||||
"connector_id": target.id,
|
||||
"connector_type": _connector_type_value(target.connector_type),
|
||||
},
|
||||
groups={"workspace": str(workspace_id)} if workspace_id is not None else None,
|
||||
)
|
||||
|
||||
|
||||
sa_event.listen(SearchSourceConnector, "after_insert", _emit_connector_connected)
|
||||
|
||||
|
||||
# Use Pydantic's BaseModel here
|
||||
class GitHubPATRequest(BaseModel):
|
||||
github_pat: str = Field(..., description="GitHub Personal Access Token")
|
||||
|
|
@ -244,6 +285,10 @@ async def create_search_source_connector(
|
|||
await session.commit()
|
||||
await session.refresh(db_connector)
|
||||
|
||||
# ``connector_connected`` is emitted by the after_insert listener below,
|
||||
# so it fires once for this form route AND all 15 OAuth callback routes
|
||||
# without instrumenting each — see _emit_connector_connected.
|
||||
|
||||
# Create periodic schedule if periodic indexing is enabled
|
||||
if (
|
||||
db_connector.periodic_indexing_enabled
|
||||
|
|
@ -679,10 +724,23 @@ async def delete_search_source_connector(
|
|||
|
||||
# Delete the connector record
|
||||
workspace_id = db_connector.workspace_id
|
||||
deleted_connector_type = db_connector.connector_type
|
||||
is_mcp = db_connector.connector_type == SearchSourceConnectorType.MCP_CONNECTOR
|
||||
await session.delete(db_connector)
|
||||
await session.commit()
|
||||
|
||||
# Authoritative deletion (migrated from use-connector-dialog.ts).
|
||||
ph_analytics.capture_for(
|
||||
auth,
|
||||
"connector_deleted",
|
||||
{
|
||||
"workspace_id": workspace_id,
|
||||
"connector_id": connector_id,
|
||||
"connector_type": _connector_type_value(deleted_connector_type),
|
||||
},
|
||||
groups={"workspace": str(workspace_id)},
|
||||
)
|
||||
|
||||
if is_mcp:
|
||||
from app.agents.chat.multi_agent_chat.shared.tools.mcp.tool import (
|
||||
invalidate_mcp_tools_cache,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from app.db import (
|
|||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.schemas.stripe import (
|
||||
AutoReloadSettingsResponse,
|
||||
CreateAutoReloadSetupSessionRequest,
|
||||
|
|
@ -47,6 +48,26 @@ logger = logging.getLogger(__name__)
|
|||
router = APIRouter(prefix="/stripe", tags=["stripe"])
|
||||
|
||||
|
||||
def _capture_credits_purchased(user: User, purchase: CreditPurchase) -> None:
|
||||
"""Emit ``credits_purchased`` — revenue events only exist server-side.
|
||||
|
||||
Call only from the idempotent grant paths (which early-return on already
|
||||
COMPLETED / non-PENDING rows) so Stripe retries never double-count. No-op
|
||||
when PostHog is unconfigured.
|
||||
"""
|
||||
ph_analytics.capture(
|
||||
"credits_purchased",
|
||||
distinct_id=str(user.id),
|
||||
properties={
|
||||
"credit_micros_granted": purchase.credit_micros_granted,
|
||||
"quantity": purchase.quantity,
|
||||
"amount_total": purchase.amount_total,
|
||||
"currency": purchase.currency,
|
||||
"source": purchase.source,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_stripe_client() -> StripeClient:
|
||||
"""Return a configured Stripe client or raise if Stripe is disabled."""
|
||||
if not config.STRIPE_SECRET_KEY:
|
||||
|
|
@ -309,6 +330,7 @@ async def _fulfill_completed_credit_purchase(
|
|||
)
|
||||
|
||||
await db_session.commit()
|
||||
_capture_credits_purchased(user, purchase)
|
||||
return StripeWebhookResponse()
|
||||
|
||||
|
||||
|
|
@ -448,6 +470,8 @@ async def _reconcile_auto_reload_payment_intent(
|
|||
purchase.status = CreditPurchaseStatus.FAILED
|
||||
|
||||
await db_session.commit()
|
||||
if succeeded:
|
||||
_capture_credits_purchased(user, purchase)
|
||||
return StripeWebhookResponse()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from app.db import (
|
|||
get_async_session,
|
||||
get_default_roles_config,
|
||||
)
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.routes.model_connections_routes import compute_llm_setup_status
|
||||
from app.schemas import (
|
||||
WorkspaceApiAccessUpdate,
|
||||
|
|
@ -112,6 +113,16 @@ async def create_workspace(
|
|||
await session.commit()
|
||||
await session.refresh(db_workspace)
|
||||
|
||||
# Authoritative creation event (migrated from the frontend
|
||||
# CreateWorkspaceDialog). Workspace name is intentionally NOT sent —
|
||||
# it's user content with no aggregation value.
|
||||
ph_analytics.capture_for(
|
||||
auth,
|
||||
"workspace_created",
|
||||
{"workspace_id": db_workspace.id},
|
||||
groups={"workspace": str(db_workspace.id)},
|
||||
)
|
||||
|
||||
response = WorkspaceRead.model_validate(db_workspace)
|
||||
response.llm_setup = await compute_llm_setup_status(
|
||||
session, auth, db_workspace.id
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Celery tasks for connector indexing."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
|
|
@ -8,6 +9,7 @@ from collections.abc import Awaitable, Callable
|
|||
from celery import current_task
|
||||
|
||||
from app.celery_app import celery_app
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.observability import metrics as ot_metrics, otel as ot
|
||||
from app.tasks.celery_tasks import (
|
||||
get_celery_session_maker,
|
||||
|
|
@ -17,8 +19,18 @@ from app.tasks.celery_tasks import (
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T:
|
||||
"""Run connector sync work and record aggregate connector metrics."""
|
||||
def run_async_celery_task[T](
|
||||
coro_factory: Callable[[], Awaitable[T]],
|
||||
*,
|
||||
workspace_id: int | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> T:
|
||||
"""Run connector sync work and record aggregate connector metrics.
|
||||
|
||||
When ``workspace_id``/``user_id`` are provided, also emits the PostHog
|
||||
``connector_indexing_completed``/``_failed`` outcome (the frontend only
|
||||
knows indexing *started*, never whether it worked). No-op without a key.
|
||||
"""
|
||||
task_name = getattr(current_task, "name", None) or "unknown"
|
||||
t0 = time.perf_counter()
|
||||
status = "failed"
|
||||
|
|
@ -45,6 +57,29 @@ def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T:
|
|||
status=status,
|
||||
error_category=error_category,
|
||||
)
|
||||
if user_id and ph_analytics.is_enabled():
|
||||
event = (
|
||||
"connector_indexing_completed"
|
||||
if status == "success"
|
||||
else "connector_indexing_failed"
|
||||
)
|
||||
with contextlib.suppress(Exception):
|
||||
ph_analytics.capture(
|
||||
event,
|
||||
distinct_id=str(user_id),
|
||||
properties={
|
||||
"workspace_id": workspace_id,
|
||||
# ``connector_type`` is the Celery task name, e.g.
|
||||
# index_notion_pages / index_github_repos.
|
||||
"connector_type": task_name,
|
||||
"status": status,
|
||||
"error_category": error_category,
|
||||
"duration_ms": int(elapsed_s * 1000),
|
||||
},
|
||||
groups={"workspace": str(workspace_id)}
|
||||
if workspace_id is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
|
||||
def _handle_greenlet_error(e: Exception, task_name: str, connector_id: int) -> None:
|
||||
|
|
@ -91,7 +126,9 @@ def index_notion_pages_task(
|
|||
return run_async_celery_task(
|
||||
lambda: _index_notion_pages(
|
||||
connector_id, workspace_id, user_id, start_date, end_date
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
_handle_greenlet_error(e, "index_notion_pages", connector_id)
|
||||
|
|
@ -129,7 +166,9 @@ def index_github_repos_task(
|
|||
return run_async_celery_task(
|
||||
lambda: _index_github_repos(
|
||||
connector_id, workspace_id, user_id, start_date, end_date
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -164,7 +203,9 @@ def index_confluence_pages_task(
|
|||
return run_async_celery_task(
|
||||
lambda: _index_confluence_pages(
|
||||
connector_id, workspace_id, user_id, start_date, end_date
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -200,7 +241,9 @@ def index_google_calendar_events_task(
|
|||
return run_async_celery_task(
|
||||
lambda: _index_google_calendar_events(
|
||||
connector_id, workspace_id, user_id, start_date, end_date
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except Exception as e:
|
||||
_handle_greenlet_error(e, "index_google_calendar_events", connector_id)
|
||||
|
|
@ -238,7 +281,9 @@ def index_google_gmail_messages_task(
|
|||
return run_async_celery_task(
|
||||
lambda: _index_google_gmail_messages(
|
||||
connector_id, workspace_id, user_id, start_date, end_date
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -275,7 +320,9 @@ def index_google_drive_files_task(
|
|||
workspace_id,
|
||||
user_id,
|
||||
items_dict,
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -315,7 +362,9 @@ def index_onedrive_files_task(
|
|||
workspace_id,
|
||||
user_id,
|
||||
items_dict,
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -355,7 +404,9 @@ def index_dropbox_files_task(
|
|||
workspace_id,
|
||||
user_id,
|
||||
items_dict,
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -393,7 +444,9 @@ def index_elasticsearch_documents_task(
|
|||
return run_async_celery_task(
|
||||
lambda: _index_elasticsearch_documents(
|
||||
connector_id, workspace_id, user_id, start_date, end_date
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -428,7 +481,9 @@ def index_bookstack_pages_task(
|
|||
return run_async_celery_task(
|
||||
lambda: _index_bookstack_pages(
|
||||
connector_id, workspace_id, user_id, start_date, end_date
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -463,7 +518,9 @@ def index_composio_connector_task(
|
|||
return run_async_celery_task(
|
||||
lambda: _index_composio_connector(
|
||||
connector_id, workspace_id, user_id, start_date, end_date
|
||||
)
|
||||
),
|
||||
workspace_id=workspace_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import asyncio
|
|||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from uuid import UUID
|
||||
|
||||
from app.celery_app import celery_app
|
||||
from app.config import config
|
||||
from app.notifications.service import NotificationService
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.observability import metrics as ot_metrics
|
||||
from app.services.task_logging_service import TaskLoggingService
|
||||
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
|
||||
|
|
@ -22,6 +24,44 @@ from app.tasks.document_processors import (
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _capture_doc_processing(
|
||||
status: str,
|
||||
*,
|
||||
user_id: str | None,
|
||||
workspace_id: int,
|
||||
doc_type: str,
|
||||
file_size: int | None = None,
|
||||
duration_ms: int | None = None,
|
||||
) -> None:
|
||||
"""Emit ``document_processing_completed``/``_failed`` from a Celery task.
|
||||
|
||||
The frontend only knows the upload POST succeeded, never whether ingestion
|
||||
actually worked — this is the authoritative outcome. No-op when PostHog is
|
||||
unconfigured. ``distinct_id`` is the owning user's id so it joins the same
|
||||
person the web app identifies.
|
||||
"""
|
||||
if not ph_analytics.is_enabled() or not user_id:
|
||||
return
|
||||
event = (
|
||||
"document_processing_completed"
|
||||
if status == "success"
|
||||
else "document_processing_failed"
|
||||
)
|
||||
ph_analytics.capture(
|
||||
event,
|
||||
distinct_id=str(user_id),
|
||||
properties={
|
||||
"workspace_id": workspace_id,
|
||||
"doc_type": doc_type,
|
||||
"file_size": file_size,
|
||||
"duration_ms": duration_ms,
|
||||
"status": status,
|
||||
},
|
||||
groups={"workspace": str(workspace_id)},
|
||||
)
|
||||
|
||||
|
||||
# ===== Redis heartbeat for document processing tasks =====
|
||||
# Same mechanism as connector indexing heartbeats (search_source_connectors_routes.py).
|
||||
# A background coroutine refreshes a Redis key every 60s with a 2-min TTL.
|
||||
|
|
@ -268,11 +308,30 @@ def process_extension_document_task(
|
|||
workspace_id: ID of the workspace
|
||||
user_id: ID of the user
|
||||
"""
|
||||
return run_async_celery_task(
|
||||
lambda: _process_extension_document(
|
||||
individual_document_dict, workspace_id, user_id
|
||||
_t0 = time.perf_counter()
|
||||
try:
|
||||
result = run_async_celery_task(
|
||||
lambda: _process_extension_document(
|
||||
individual_document_dict, workspace_id, user_id
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
_capture_doc_processing(
|
||||
"failed",
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
doc_type="extension",
|
||||
duration_ms=int((time.perf_counter() - _t0) * 1000),
|
||||
)
|
||||
raise
|
||||
_capture_doc_processing(
|
||||
"success",
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
doc_type="extension",
|
||||
duration_ms=int((time.perf_counter() - _t0) * 1000),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _process_extension_document(
|
||||
|
|
@ -430,12 +489,14 @@ def process_file_upload_task(
|
|||
)
|
||||
return
|
||||
|
||||
file_size: int | None = None
|
||||
try:
|
||||
file_size = os.path.getsize(file_path)
|
||||
logger.info(f"[process_file_upload] File size: {file_size} bytes")
|
||||
except Exception as e:
|
||||
logger.warning(f"[process_file_upload] Could not get file size: {e}")
|
||||
|
||||
_t0 = time.perf_counter()
|
||||
try:
|
||||
run_async_celery_task(
|
||||
lambda: _process_file_upload(file_path, filename, workspace_id, user_id)
|
||||
|
|
@ -448,7 +509,23 @@ def process_file_upload_task(
|
|||
f"[process_file_upload] Task failed for {filename}: {e}\n"
|
||||
f"Traceback:\n{traceback.format_exc()}"
|
||||
)
|
||||
_capture_doc_processing(
|
||||
"failed",
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
doc_type="file_upload",
|
||||
file_size=file_size,
|
||||
duration_ms=int((time.perf_counter() - _t0) * 1000),
|
||||
)
|
||||
raise
|
||||
_capture_doc_processing(
|
||||
"success",
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
doc_type="file_upload",
|
||||
file_size=file_size,
|
||||
duration_ms=int((time.perf_counter() - _t0) * 1000),
|
||||
)
|
||||
|
||||
|
||||
async def _process_file_upload(
|
||||
|
|
@ -682,6 +759,7 @@ def process_file_upload_with_document_task(
|
|||
)
|
||||
return
|
||||
|
||||
_t0 = time.perf_counter()
|
||||
try:
|
||||
run_async_celery_task(
|
||||
lambda: _process_file_with_document(
|
||||
|
|
@ -702,7 +780,21 @@ def process_file_upload_with_document_task(
|
|||
f"[process_file_upload_with_document] Task failed for {filename}: {e}\n"
|
||||
f"Traceback:\n{traceback.format_exc()}"
|
||||
)
|
||||
_capture_doc_processing(
|
||||
"failed",
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
doc_type="file_upload_2phase",
|
||||
duration_ms=int((time.perf_counter() - _t0) * 1000),
|
||||
)
|
||||
raise
|
||||
_capture_doc_processing(
|
||||
"success",
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
doc_type="file_upload_2phase",
|
||||
duration_ms=int((time.perf_counter() - _t0) * 1000),
|
||||
)
|
||||
|
||||
|
||||
async def _mark_document_failed(document_id: int, reason: str):
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from app.agents.video_presentation.state import State as VideoPresentationState
|
|||
from app.celery_app import celery_app
|
||||
from app.config import config as app_config
|
||||
from app.db import VideoPresentation, VideoPresentationStatus
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.services.billable_calls import (
|
||||
BillingSettlementError,
|
||||
QuotaInsufficientError,
|
||||
|
|
@ -239,6 +240,20 @@ async def _generate_video_presentation(
|
|||
|
||||
logger.info(f"Successfully generated video presentation: {video_pres.id}")
|
||||
|
||||
# Credit-consuming deliverable — the frontend never confirms
|
||||
# completion. Attributed to the workspace owner resolved above.
|
||||
if owner_user_id:
|
||||
ph_analytics.capture(
|
||||
"video_presentation_generated",
|
||||
distinct_id=str(owner_user_id),
|
||||
properties={
|
||||
"workspace_id": workspace_id,
|
||||
"video_presentation_id": video_pres.id,
|
||||
"slide_count": len(serializable_slides),
|
||||
},
|
||||
groups={"workspace": str(workspace_id)},
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ready",
|
||||
"video_presentation_id": video_pres.id,
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import (
|
|||
log_rate_limit_recovered,
|
||||
reroute_to_next_auto_pin,
|
||||
)
|
||||
from app.tasks.chat.streaming.flows.shared.analytics import (
|
||||
build_llm_callback_handler,
|
||||
capture_chat_turn_completed,
|
||||
)
|
||||
from app.tasks.chat.streaming.flows.shared.span import (
|
||||
close_chat_request_span,
|
||||
open_chat_request_span,
|
||||
|
|
@ -487,6 +491,23 @@ async def stream_new_chat(
|
|||
"recursion_limit": 10_000,
|
||||
}
|
||||
|
||||
# PostHog LLM analytics: attach a callback so the full agent trace
|
||||
# tree (LLM calls, tools, subagents) is captured per turn. No-op when
|
||||
# PostHog is unconfigured.
|
||||
_llm_handler = build_llm_callback_handler(
|
||||
distinct_id=user_id,
|
||||
trace_id=stream_result.turn_id,
|
||||
properties={
|
||||
"workspace_id": workspace_id,
|
||||
"chat_id": chat_id,
|
||||
"$ai_session_id": str(chat_id),
|
||||
"flow": flow,
|
||||
},
|
||||
groups={"workspace": str(workspace_id)},
|
||||
)
|
||||
if _llm_handler is not None:
|
||||
config["callbacks"] = [_llm_handler]
|
||||
|
||||
# --- Block 4: First SSE frames ---
|
||||
|
||||
for sse in iter_initial_frames(
|
||||
|
|
@ -830,6 +851,27 @@ async def stream_new_chat(
|
|||
log_prefix="stream_new_chat",
|
||||
)
|
||||
|
||||
# Authoritative server-side product event. Inside the shield so it
|
||||
# survives client-disconnect cancellation (abandoned tabs still
|
||||
# produce a turn event), and while ``stream_result`` is still live
|
||||
# (it's dropped to None below for GC).
|
||||
capture_chat_turn_completed(
|
||||
flow=flow,
|
||||
outcome=chat_outcome,
|
||||
error_category=chat_error_category,
|
||||
workspace_id=workspace_id,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
auth_context=auth_context,
|
||||
agent_mode=chat_agent_mode,
|
||||
client_platform=fs_platform,
|
||||
filesystem_mode=fs_mode,
|
||||
turn_id=getattr(stream_result, "turn_id", None),
|
||||
request_id=request_id,
|
||||
duration_ms=int((time.perf_counter() - _t_total) * 1000),
|
||||
accumulator=accumulator,
|
||||
)
|
||||
|
||||
# Persist any sandbox-produced files to local storage so they remain
|
||||
# downloadable after the Daytona sandbox auto-deletes.
|
||||
if stream_result and stream_result.sandbox_files:
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ from app.tasks.chat.streaming.flows.shared.rate_limit_recovery import (
|
|||
log_rate_limit_recovered,
|
||||
reroute_to_next_auto_pin,
|
||||
)
|
||||
from app.tasks.chat.streaming.flows.shared.analytics import (
|
||||
build_llm_callback_handler,
|
||||
capture_chat_turn_completed,
|
||||
)
|
||||
from app.tasks.chat.streaming.flows.shared.span import (
|
||||
close_chat_request_span,
|
||||
open_chat_request_span,
|
||||
|
|
@ -387,6 +391,22 @@ async def stream_resume_chat(
|
|||
"recursion_limit": 10_000,
|
||||
}
|
||||
|
||||
# PostHog LLM analytics — same trace as the original turn's
|
||||
# conversation via ``$ai_session_id``. No-op when unconfigured.
|
||||
_llm_handler = build_llm_callback_handler(
|
||||
distinct_id=user_id,
|
||||
trace_id=stream_result.turn_id,
|
||||
properties={
|
||||
"workspace_id": workspace_id,
|
||||
"chat_id": chat_id,
|
||||
"$ai_session_id": str(chat_id),
|
||||
"flow": "resume",
|
||||
},
|
||||
groups={"workspace": str(workspace_id)},
|
||||
)
|
||||
if _llm_handler is not None:
|
||||
config["callbacks"] = [_llm_handler]
|
||||
|
||||
# --- First SSE frames ---
|
||||
|
||||
for sse in iter_initial_frames(
|
||||
|
|
@ -599,6 +619,25 @@ async def stream_resume_chat(
|
|||
log_prefix="stream_resume",
|
||||
)
|
||||
|
||||
# Authoritative server-side product event (see new_chat
|
||||
# orchestrator for rationale). ``flow="resume"``.
|
||||
capture_chat_turn_completed(
|
||||
flow="resume",
|
||||
outcome=chat_outcome,
|
||||
error_category=chat_error_category,
|
||||
workspace_id=workspace_id,
|
||||
chat_id=chat_id,
|
||||
user_id=user_id,
|
||||
auth_context=auth_context,
|
||||
agent_mode=chat_agent_mode,
|
||||
client_platform=fs_platform,
|
||||
filesystem_mode=fs_mode,
|
||||
turn_id=getattr(stream_result, "turn_id", None),
|
||||
request_id=request_id,
|
||||
duration_ms=int((time.perf_counter() - _t_total) * 1000),
|
||||
accumulator=accumulator,
|
||||
)
|
||||
|
||||
# Release the lock from the original interrupted turn or any
|
||||
# re-interrupt/bailout. Skip on ``BusyError`` (lock not held here).
|
||||
if not busy_error_raised:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
"""PostHog chat-turn analytics for streaming flows.
|
||||
|
||||
Emits a single authoritative ``chat_turn_completed`` product event per turn,
|
||||
shared by the new-chat and resume orchestrators so every chat source (web,
|
||||
desktop, PAT scripts, gateway, automations) is tracked identically —
|
||||
including sources the frontend can never observe. No-op when PostHog is
|
||||
unconfigured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.config import config
|
||||
from app.observability import analytics
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.auth.context import AuthContext
|
||||
from app.services.token_tracking_service import TurnTokenAccumulator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_llm_callback_handler(
|
||||
*,
|
||||
distinct_id: str | None,
|
||||
trace_id: str | None,
|
||||
properties: dict[str, Any] | None = None,
|
||||
groups: dict[str, str] | None = None,
|
||||
) -> Any | None:
|
||||
"""Build a PostHog LangChain ``CallbackHandler`` for a chat turn.
|
||||
|
||||
Attaching this to the LangGraph ``config["callbacks"]`` captures the full
|
||||
agent trace tree ($ai_trace / $ai_span / $ai_generation) — every LLM call,
|
||||
tool, subagent, and retriever — joined to the ``chat_turn_completed`` event
|
||||
via ``trace_id`` (the turn id) and grouped per conversation via
|
||||
``$ai_session_id`` (in ``properties``).
|
||||
|
||||
Returns ``None`` when PostHog is disabled or the package/handler is
|
||||
unavailable, so callers can simply skip attaching callbacks. ``privacy_mode``
|
||||
(default on) suppresses prompt/completion bodies — chat content includes
|
||||
users' private documents.
|
||||
"""
|
||||
client_obj = analytics.get_client()
|
||||
if client_obj is None or not distinct_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
from posthog.ai.langchain import CallbackHandler
|
||||
|
||||
return CallbackHandler(
|
||||
client=client_obj,
|
||||
distinct_id=distinct_id,
|
||||
trace_id=trace_id,
|
||||
properties=properties or {},
|
||||
privacy_mode=config.POSTHOG_AI_PRIVACY_MODE,
|
||||
groups=groups,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("PostHog LLM callback handler unavailable", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def capture_chat_turn_completed(
|
||||
*,
|
||||
flow: str,
|
||||
outcome: str,
|
||||
error_category: str | None,
|
||||
workspace_id: int,
|
||||
chat_id: int,
|
||||
user_id: str | None,
|
||||
auth_context: AuthContext | None,
|
||||
agent_mode: str,
|
||||
client_platform: str,
|
||||
filesystem_mode: str,
|
||||
turn_id: str | None,
|
||||
request_id: str | None,
|
||||
duration_ms: int,
|
||||
accumulator: TurnTokenAccumulator,
|
||||
) -> None:
|
||||
"""Capture ``chat_turn_completed``. Best-effort; never raises."""
|
||||
if not analytics.is_enabled() or not user_id:
|
||||
return
|
||||
|
||||
props: dict[str, Any] = {
|
||||
"flow": flow,
|
||||
"outcome": outcome,
|
||||
"error_category": error_category,
|
||||
"workspace_id": workspace_id,
|
||||
"chat_id": chat_id,
|
||||
"agent_mode": agent_mode,
|
||||
"client_platform": client_platform,
|
||||
"filesystem_mode": filesystem_mode,
|
||||
"turn_id": turn_id,
|
||||
"request_id": request_id,
|
||||
"duration_ms": duration_ms,
|
||||
# Cost is micro-USD (integer), matching TurnTokenAccumulator; do not
|
||||
# convert to float dollars.
|
||||
"total_tokens": accumulator.grand_total,
|
||||
"prompt_tokens": accumulator.total_prompt_tokens,
|
||||
"completion_tokens": accumulator.total_completion_tokens,
|
||||
"cost_micros": accumulator.total_cost_micros,
|
||||
}
|
||||
groups = {"workspace": str(workspace_id)}
|
||||
|
||||
if auth_context is not None:
|
||||
analytics.capture_for(
|
||||
auth_context, "chat_turn_completed", props, groups=groups
|
||||
)
|
||||
else:
|
||||
analytics.capture(
|
||||
"chat_turn_completed",
|
||||
distinct_id=user_id,
|
||||
properties=props,
|
||||
groups=groups,
|
||||
)
|
||||
|
|
@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from app.auth.context import AuthContext
|
||||
from app.auth.session_cookies import access_expires_at, write_session
|
||||
from app.config import config
|
||||
from app.observability import analytics as ph_analytics
|
||||
from app.db import (
|
||||
Prompt,
|
||||
User,
|
||||
|
|
@ -144,6 +145,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
|||
except Exception as e:
|
||||
logger.warning(f"Failed to update last_login for user {user.id}: {e}")
|
||||
|
||||
# Authoritative login event (vs. the frontend's optimistic capture).
|
||||
ph_analytics.capture("auth_login_success", distinct_id=str(user.id))
|
||||
|
||||
async def on_after_register(self, user: User, request: Request | None = None):
|
||||
"""
|
||||
Called after a user registers. Creates a default workspace for the user
|
||||
|
|
@ -206,6 +210,17 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
|||
logger.info(
|
||||
f"Created default workspace (ID: {default_workspace.id}) for user {user.id}"
|
||||
)
|
||||
|
||||
# Authoritative registration + auto-created default workspace.
|
||||
ph_analytics.capture(
|
||||
"auth_registration_success", distinct_id=str(user.id)
|
||||
)
|
||||
ph_analytics.capture(
|
||||
"workspace_created",
|
||||
distinct_id=str(user.id),
|
||||
properties={"client": "auto_register"},
|
||||
groups={"workspace": str(default_workspace.id)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create default workspace for user {user.id}: {e}")
|
||||
|
||||
|
|
@ -338,6 +353,13 @@ async def get_auth_context(
|
|||
FastAPI-Users still handles JWT mechanics; PATs are resolved here so RBAC
|
||||
receives the full SurfSense principal instead of a bare User.
|
||||
"""
|
||||
def _stash(ctx: AuthContext) -> AuthContext:
|
||||
# Expose the resolved principal on request.state so downstream
|
||||
# middleware (e.g. PostHog pat_api_request attribution) can read it
|
||||
# without re-resolving auth.
|
||||
request.state.auth_context = ctx
|
||||
return ctx
|
||||
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header:
|
||||
scheme, _, credential = auth_header.partition(" ")
|
||||
|
|
@ -348,7 +370,7 @@ async def get_auth_context(
|
|||
pat = await resolve_pat(session, token)
|
||||
if pat and pat.user and pat.user.is_active:
|
||||
maybe_touch_last_used(pat)
|
||||
return AuthContext.pat_auth(pat.user, pat)
|
||||
return _stash(AuthContext.pat_auth(pat.user, pat))
|
||||
|
||||
if is_bearer and _token_meets_epoch(token):
|
||||
try:
|
||||
|
|
@ -358,7 +380,7 @@ async def get_auth_context(
|
|||
user = None
|
||||
|
||||
if user and user.is_active:
|
||||
return AuthContext.session(user)
|
||||
return _stash(AuthContext.session(user))
|
||||
|
||||
cookie_token = request.cookies.get(config.SESSION_COOKIE_NAME)
|
||||
if cookie_token and _token_meets_epoch(cookie_token):
|
||||
|
|
@ -369,7 +391,7 @@ async def get_auth_context(
|
|||
user = None
|
||||
|
||||
if user and user.is_active:
|
||||
return AuthContext.session(user)
|
||||
return _stash(AuthContext.session(user))
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ dependencies = [
|
|||
"python-telegram-bot>=22.7",
|
||||
"croniter>=2.0.0",
|
||||
"scrapling[fetchers]>=0.4.11",
|
||||
"posthog>=6.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
|
|
|||
149
surfsense_backend/tests/unit/observability/test_analytics.py
Normal file
149
surfsense_backend/tests/unit/observability/test_analytics.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Unit tests for the PostHog analytics wrapper.
|
||||
|
||||
Covers the two properties the rest of the codebase relies on:
|
||||
1. Opt-in no-op: with no client configured, every entry point is silent and
|
||||
never raises (analytics must never break a request).
|
||||
2. Correct stamping when a client IS present: ``source="backend"`` and
|
||||
``disable_geoip=True`` on every capture, plus ``auth_method`` / ``client``
|
||||
derived from the ``AuthContext`` principal by ``capture_for``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.observability import analytics
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Records capture()/group_identify() calls instead of hitting the network."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.captures: list[dict] = []
|
||||
self.groups: list[dict] = []
|
||||
|
||||
def capture(self, event, *, distinct_id, properties, groups, disable_geoip):
|
||||
self.captures.append(
|
||||
{
|
||||
"event": event,
|
||||
"distinct_id": distinct_id,
|
||||
"properties": properties,
|
||||
"groups": groups,
|
||||
"disable_geoip": disable_geoip,
|
||||
}
|
||||
)
|
||||
|
||||
def group_identify(self, *, group_type, group_key, properties):
|
||||
self.groups.append(
|
||||
{"group_type": group_type, "group_key": group_key, "properties": properties}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_module_state():
|
||||
"""Isolate the module-level lazy-client singleton between tests."""
|
||||
orig_client = analytics._client
|
||||
orig_attempted = analytics._init_attempted
|
||||
yield
|
||||
analytics._client = orig_client
|
||||
analytics._init_attempted = orig_attempted
|
||||
|
||||
|
||||
def _use_fake_client() -> _FakeClient:
|
||||
"""Inject a fake client, bypassing lazy init (no posthog import, no key)."""
|
||||
fake = _FakeClient()
|
||||
analytics._client = fake
|
||||
analytics._init_attempted = True
|
||||
return fake
|
||||
|
||||
|
||||
def _disable_client() -> None:
|
||||
analytics._client = None
|
||||
analytics._init_attempted = True
|
||||
|
||||
|
||||
# ---- No-op behaviour when disabled -------------------------------------------------
|
||||
|
||||
|
||||
def test_capture_is_noop_without_client():
|
||||
_disable_client()
|
||||
assert analytics.is_enabled() is False
|
||||
# Must not raise even though there is no client.
|
||||
analytics.capture("some_event", distinct_id="u1", properties={"a": 1})
|
||||
|
||||
|
||||
def test_capture_for_is_noop_without_client():
|
||||
_disable_client()
|
||||
auth = SimpleNamespace(method="session", source=None, user=SimpleNamespace(id="u1"))
|
||||
analytics.capture_for(auth, "some_event", {"a": 1}) # no raise
|
||||
|
||||
|
||||
def test_shutdown_is_noop_without_client():
|
||||
_disable_client()
|
||||
analytics.shutdown() # no raise
|
||||
|
||||
|
||||
# ---- Stamping when a client is present ---------------------------------------------
|
||||
|
||||
|
||||
def test_capture_stamps_source_and_disables_geoip():
|
||||
fake = _use_fake_client()
|
||||
analytics.capture(
|
||||
"chat_turn_completed",
|
||||
distinct_id="user-123",
|
||||
properties={"workspace_id": 7},
|
||||
groups={"workspace": "7"},
|
||||
)
|
||||
|
||||
assert len(fake.captures) == 1
|
||||
call = fake.captures[0]
|
||||
assert call["event"] == "chat_turn_completed"
|
||||
assert call["distinct_id"] == "user-123"
|
||||
# source is always stamped so backend vs frontend events are separable.
|
||||
assert call["properties"]["source"] == "backend"
|
||||
assert call["properties"]["workspace_id"] == 7
|
||||
assert call["groups"] == {"workspace": "7"}
|
||||
# Without this the server IP would overwrite each person's real location.
|
||||
assert call["disable_geoip"] is True
|
||||
|
||||
|
||||
def test_capture_never_raises_when_client_errors():
|
||||
class _Boom(_FakeClient):
|
||||
def capture(self, *a, **k):
|
||||
raise RuntimeError("network down")
|
||||
|
||||
boom = _Boom()
|
||||
analytics._client = boom
|
||||
analytics._init_attempted = True
|
||||
# Swallowed like the frontend safeCapture — analytics never breaks a request.
|
||||
analytics.capture("evt", distinct_id="u1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "source", "expected_client"),
|
||||
[
|
||||
("session", None, "web"),
|
||||
("pat", None, "pat"),
|
||||
("system", "gateway", "gateway"),
|
||||
("system", None, "system"),
|
||||
],
|
||||
)
|
||||
def test_capture_for_stamps_auth_method_and_client(method, source, expected_client):
|
||||
fake = _use_fake_client()
|
||||
auth = SimpleNamespace(
|
||||
method=method, source=source, user=SimpleNamespace(id="user-abc")
|
||||
)
|
||||
|
||||
analytics.capture_for(auth, "workspace_created", {"workspace_id": 1})
|
||||
|
||||
assert len(fake.captures) == 1
|
||||
props = fake.captures[0]["properties"]
|
||||
assert fake.captures[0]["distinct_id"] == "user-abc"
|
||||
assert props["auth_method"] == method
|
||||
assert props["client"] == expected_client
|
||||
assert props["source"] == "backend" # capture_for delegates to capture
|
||||
assert props["workspace_id"] == 1
|
||||
158
surfsense_backend/uv.lock
generated
158
surfsense_backend/uv.lock
generated
|
|
@ -48,6 +48,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -94,6 +97,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -140,6 +146,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -186,6 +195,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -232,6 +244,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -278,6 +293,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -324,6 +342,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -370,6 +391,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -416,6 +440,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -462,6 +489,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra == 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -523,6 +553,10 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -569,6 +603,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -615,6 +652,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version < '3.13' and sys_platform == 'linux' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -661,6 +701,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -707,6 +750,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -753,6 +799,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -799,6 +848,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -845,6 +897,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -891,6 +946,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -937,6 +995,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -983,6 +1044,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra == 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1044,6 +1108,10 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1090,6 +1158,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1136,6 +1207,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version < '3.13' and sys_platform == 'linux' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1182,6 +1256,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1228,6 +1305,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1274,6 +1354,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1320,6 +1403,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1366,6 +1452,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1412,6 +1501,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1458,6 +1550,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version < '3.13' and sys_platform != 'linux' and sys_platform != 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1504,6 +1599,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version < '3.13' and sys_platform == 'win32' and extra == 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1565,6 +1663,10 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1611,6 +1713,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1702,6 +1807,12 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1748,6 +1859,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1794,6 +1908,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform == 'emscripten' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1885,6 +2002,12 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -1976,6 +2099,12 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version < '3.13' and sys_platform != 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
|
|
@ -2022,6 +2151,9 @@ resolution-markers = [
|
|||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_version < '0'",
|
||||
"python_full_version < '3.13' and sys_platform == 'win32' and extra != 'extra-16-surf-new-backend-cpu' and extra != 'extra-16-surf-new-backend-cu126' and extra != 'extra-16-surf-new-backend-cu128'",
|
||||
]
|
||||
conflicts = [[
|
||||
|
|
@ -2557,6 +2689,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backoff"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "banks"
|
||||
version = "2.4.1"
|
||||
|
|
@ -8650,6 +8791,21 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "posthog"
|
||||
version = "7.28.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backoff" },
|
||||
{ name = "distro" },
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/f0/3af875ac3fd5863ed4874c9618d85eab3f7fd24b395827d99da0ef90aca6/posthog-7.28.0.tar.gz", hash = "sha256:9e048dee58f27373db622c0744be30c1a2b7f1df31049956d6341c9646fb3833", size = 356360 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/8b/d41d98e64bd6ce650d45690cffc718274b1eda65e3bfd79575a1cf95b45c/posthog-7.28.0-py3-none-any.whl", hash = "sha256:4cff10062807bbd8ae6ec2804a71584831a75b390ce7ba3e736bf4cc0fb25d28", size = 425147 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "preshed"
|
||||
version = "3.0.13"
|
||||
|
|
@ -10926,6 +11082,7 @@ dependencies = [
|
|||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "opentelemetry-semantic-conventions" },
|
||||
{ name = "pgvector" },
|
||||
{ name = "posthog" },
|
||||
{ name = "psycopg", extra = ["binary", "pool"] },
|
||||
{ name = "pyarrow" },
|
||||
{ name = "pyjwt" },
|
||||
|
|
@ -11041,6 +11198,7 @@ requires-dist = [
|
|||
{ name = "opentelemetry-sdk", specifier = ">=1.40.0" },
|
||||
{ name = "opentelemetry-semantic-conventions", specifier = ">=0.61b0" },
|
||||
{ name = "pgvector", specifier = ">=0.3.6" },
|
||||
{ name = "posthog", specifier = ">=6.0.0" },
|
||||
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" },
|
||||
{ name = "pyarrow", specifier = ">=15.0.0,<19.0.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.12.0" },
|
||||
|
|
|
|||
|
|
@ -37,7 +37,11 @@ class SurfSenseClient:
|
|||
self._fallback_api_key = fallback_api_key
|
||||
self._http = httpx.AsyncClient(
|
||||
base_url=api_base,
|
||||
headers={"Accept": "application/json"},
|
||||
# ``X-SurfSense-Client`` lets the backend distinguish PAT traffic
|
||||
# originating from this MCP server vs. raw PAT scripts, so
|
||||
# "documents added via MCP" / "searches via MCP" are queryable.
|
||||
# Server-to-server, so no CORS implications.
|
||||
headers={"Accept": "application/json", "X-SurfSense-Client": "mcp"},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
|
||||
import { getPostLoginRedirectPath } from "@/lib/auth-utils";
|
||||
import { ValidationError } from "@/lib/error";
|
||||
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
|
||||
import { trackLoginAttempt, trackLoginFailure } from "@/lib/posthog/events";
|
||||
|
||||
export function LocalLoginForm() {
|
||||
const t = useTranslations("auth");
|
||||
|
|
@ -45,8 +45,8 @@ export function LocalLoginForm() {
|
|||
grant_type: "password",
|
||||
});
|
||||
|
||||
// Track successful login
|
||||
trackLoginSuccess("local");
|
||||
// auth_login_success is now emitted server-side
|
||||
// (UserManager.on_after_login) — authoritative vs. optimistic.
|
||||
|
||||
// Small delay to show success message
|
||||
setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -15,11 +15,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import { useSession } from "@/hooks/use-session";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AppError, ValidationError } from "@/lib/error";
|
||||
import {
|
||||
trackRegistrationAttempt,
|
||||
trackRegistrationFailure,
|
||||
trackRegistrationSuccess,
|
||||
} from "@/lib/posthog/events";
|
||||
import { trackRegistrationAttempt, trackRegistrationFailure } from "@/lib/posthog/events";
|
||||
import { AmbientBackground } from "../login/AmbientBackground";
|
||||
|
||||
export default function RegisterPage() {
|
||||
|
|
@ -81,8 +77,8 @@ export default function RegisterPage() {
|
|||
is_verified: false,
|
||||
});
|
||||
|
||||
// Track successful registration
|
||||
trackRegistrationSuccess();
|
||||
// auth_registration_success is now emitted server-side
|
||||
// (UserManager.on_after_register) — authoritative vs. optimistic.
|
||||
|
||||
// Success toast
|
||||
toast.success(t("register_success"), {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { useEffect } from "react";
|
|||
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
|
||||
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { LayoutDataProvider } from "@/components/layout";
|
||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||
|
|
@ -169,7 +168,6 @@ export function DashboardClientLayout({
|
|||
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
|
||||
>
|
||||
{children}
|
||||
<ConnectorIndicator showTrigger={false} />
|
||||
</LayoutDataProvider>
|
||||
</DocumentUploadDialogProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ export async function GET(
|
|||
const response = new NextResponse(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `/dashboard/${workspace_id}/new-chat`,
|
||||
// Land on the connectors panel so `useConnectorDialog` (mounted there)
|
||||
// consumes the result cookie and continues the indexing/edit flow.
|
||||
Location: `/dashboard/${workspace_id}/connectors`,
|
||||
},
|
||||
});
|
||||
response.cookies.set(OAUTH_RESULT_COOKIE, result, {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import { ConnectorsSection } from "@/components/assistant-ui/connector-popup/connectors-panel";
|
||||
|
||||
export default function ConnectorsPage() {
|
||||
return <ConnectorsSection />;
|
||||
}
|
||||
|
|
@ -96,7 +96,6 @@ import type { Role } from "@/contracts/types/roles.types";
|
|||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { trackWorkspaceInviteSent, trackWorkspaceUsersViewed } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -226,12 +225,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
|
|||
const canPrev = pageIndex > 0;
|
||||
const canNext = displayEnd < totalItems;
|
||||
|
||||
useEffect(() => {
|
||||
if (members.length > 0 && !membersLoading) {
|
||||
const ownerCount = members.filter((m) => m.is_owner).length;
|
||||
trackWorkspaceUsersViewed(workspaceId, members.length, ownerCount);
|
||||
}
|
||||
}, [members, membersLoading, workspaceId]);
|
||||
// workspace_users_viewed removed — redundant with $pageview.
|
||||
|
||||
if (accessLoading || membersLoading) {
|
||||
return (
|
||||
|
|
@ -342,11 +336,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
|
|||
Invite members
|
||||
</Button>
|
||||
) : (
|
||||
<CreateInviteDialog
|
||||
roles={roles}
|
||||
onCreateInvite={handleCreateInvite}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
<CreateInviteDialog roles={roles} onCreateInvite={handleCreateInvite} />
|
||||
)}
|
||||
{invitesLoading ? (
|
||||
<Button
|
||||
|
|
@ -617,11 +607,9 @@ function MemberRow({
|
|||
function CreateInviteDialog({
|
||||
roles,
|
||||
onCreateInvite,
|
||||
workspaceId,
|
||||
}: {
|
||||
roles: Role[];
|
||||
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
|
||||
workspaceId: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
|
@ -653,12 +641,7 @@ function CreateInviteDialog({
|
|||
const invite = await onCreateInvite(data);
|
||||
setCreatedInvite(invite);
|
||||
|
||||
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined;
|
||||
trackWorkspaceInviteSent(workspaceId, {
|
||||
roleName,
|
||||
hasExpiry: !!expiresAt,
|
||||
hasMaxUses: !!maxUses,
|
||||
});
|
||||
// workspace_invite_sent is now emitted server-side (rbac_routes.create_invite).
|
||||
} catch (error) {
|
||||
console.error("Failed to create invite:", error);
|
||||
toast.error("Failed to create invite. Please try again.");
|
||||
|
|
|
|||
|
|
@ -43,21 +43,15 @@ export default function DashboardError({
|
|||
<Button type="button" onClick={reset}>
|
||||
Try again
|
||||
</Button>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
Go to dashboard home
|
||||
</Link>
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Report Issue
|
||||
</a>
|
||||
<Button asChild variant="ghost">
|
||||
<Link href="/dashboard">Back to dashboard</Link>
|
||||
</Button>
|
||||
<Button asChild variant="ghost">
|
||||
<a href={issueUrl} target="_blank" rel="noopener noreferrer">
|
||||
Report Issue
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { motion } from "motion/react";
|
|||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { use, useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -33,11 +33,7 @@ import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
|
|||
import { useSession } from "@/hooks/use-session";
|
||||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||
import { setRedirectPath } from "@/lib/auth-utils";
|
||||
import {
|
||||
trackWorkspaceInviteAccepted,
|
||||
trackWorkspaceInviteDeclined,
|
||||
trackWorkspaceUserAdded,
|
||||
} from "@/lib/posthog/events";
|
||||
import { trackWorkspaceInviteDeclined } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
|
|
@ -96,9 +92,9 @@ export default function InviteAcceptPage() {
|
|||
setAccepted(true);
|
||||
setAcceptedData(result);
|
||||
|
||||
// Track invite accepted and user added events
|
||||
trackWorkspaceInviteAccepted(result.workspace_id, result.workspace_name, result.role_name);
|
||||
trackWorkspaceUserAdded(result.workspace_id, result.workspace_name, result.role_name);
|
||||
// workspace_invite_accepted + workspace_user_added are now emitted
|
||||
// server-side (rbac_routes.accept_invite) — the server redirect is
|
||||
// the authoritative join point.
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to accept invite");
|
||||
|
|
|
|||
|
|
@ -8,18 +8,11 @@ import type {
|
|||
} from "@/contracts/types/automation.types";
|
||||
import { automationsApiService } from "@/lib/apis/automations-api.service";
|
||||
import {
|
||||
trackAutomationCreated,
|
||||
trackAutomationCreateFailed,
|
||||
trackAutomationDeleted,
|
||||
trackAutomationDeleteFailed,
|
||||
trackAutomationStatusChanged,
|
||||
trackAutomationTriggerAdded,
|
||||
trackAutomationTriggerAddFailed,
|
||||
trackAutomationTriggerRemoved,
|
||||
trackAutomationTriggerRemoveFailed,
|
||||
trackAutomationTriggerUpdated,
|
||||
trackAutomationTriggerUpdateFailed,
|
||||
trackAutomationUpdated,
|
||||
trackAutomationUpdateFailed,
|
||||
} from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
|
@ -48,20 +41,10 @@ export const createAutomationMutationAtom = atomWithMutation(() => ({
|
|||
mutationFn: async (request: AutomationCreateRequest) => {
|
||||
return automationsApiService.createAutomation(request);
|
||||
},
|
||||
onSuccess: (automation, variables) => {
|
||||
onSuccess: (_automation, variables) => {
|
||||
invalidateList(variables.workspace_id);
|
||||
toast.success("Automation created");
|
||||
trackAutomationCreated({
|
||||
workspace_id: variables.workspace_id,
|
||||
automation_id: automation.id,
|
||||
task_count: variables.definition.plan.length,
|
||||
trigger_type: variables.triggers?.[0]?.type ?? "none",
|
||||
has_schedule: (variables.triggers?.length ?? 0) > 0,
|
||||
chat_model_id: variables.definition.models?.chat_model_id,
|
||||
image_gen_model_id: variables.definition.models?.image_gen_model_id,
|
||||
vision_model_id: variables.definition.models?.vision_model_id,
|
||||
tags_count: variables.definition.metadata?.tags?.length,
|
||||
});
|
||||
// automation_created is now emitted server-side (AutomationService.create).
|
||||
},
|
||||
onError: (error: Error, variables) => {
|
||||
console.error("Error creating automation:", error);
|
||||
|
|
@ -82,24 +65,8 @@ export const updateAutomationMutationAtom = atomWithMutation(() => ({
|
|||
invalidateDetail(vars.automationId);
|
||||
invalidateList(automation.workspace_id);
|
||||
toast.success("Automation updated");
|
||||
// A status-only patch (pause/resume/archive) is a distinct action from a
|
||||
// definition/name edit, so split it into its own event.
|
||||
if (vars.patch.status && !vars.patch.definition) {
|
||||
trackAutomationStatusChanged({
|
||||
automation_id: vars.automationId,
|
||||
workspace_id: automation.workspace_id,
|
||||
next_status: vars.patch.status,
|
||||
});
|
||||
} else {
|
||||
trackAutomationUpdated({
|
||||
automation_id: vars.automationId,
|
||||
workspace_id: automation.workspace_id,
|
||||
has_definition_change: !!vars.patch.definition,
|
||||
has_name_change: vars.patch.name != null,
|
||||
has_description_change: vars.patch.description !== undefined,
|
||||
task_count: vars.patch.definition?.plan?.length,
|
||||
});
|
||||
}
|
||||
// automation_updated / automation_status_changed are now emitted
|
||||
// server-side (AutomationService.update).
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error updating automation:", error);
|
||||
|
|
@ -121,10 +88,7 @@ export const deleteAutomationMutationAtom = atomWithMutation(() => ({
|
|||
invalidateList(vars.workspaceId);
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Automation deleted");
|
||||
trackAutomationDeleted({
|
||||
automation_id: vars.automationId,
|
||||
workspace_id: vars.workspaceId,
|
||||
});
|
||||
// automation_deleted is now emitted server-side (AutomationService.delete).
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error deleting automation:", error);
|
||||
|
|
@ -141,16 +105,10 @@ export const addTriggerMutationAtom = atomWithMutation(() => ({
|
|||
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
|
||||
return automationsApiService.addTrigger(vars.automationId, vars.payload);
|
||||
},
|
||||
onSuccess: (trigger, vars) => {
|
||||
onSuccess: (_trigger, vars) => {
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Trigger added");
|
||||
trackAutomationTriggerAdded({
|
||||
automation_id: vars.automationId,
|
||||
trigger_id: trigger.id,
|
||||
trigger_type: trigger.type,
|
||||
enabled: trigger.enabled,
|
||||
has_cron: !!trigger.params?.cron,
|
||||
});
|
||||
// automation_trigger_added is now emitted server-side (TriggerService.add).
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error adding trigger:", error);
|
||||
|
|
@ -174,17 +132,7 @@ export const updateTriggerMutationAtom = atomWithMutation(() => ({
|
|||
onSuccess: (_, vars) => {
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Trigger updated");
|
||||
const change: "enabled" | "params" | "other" = vars.patch.params
|
||||
? "params"
|
||||
: vars.patch.enabled !== undefined && vars.patch.enabled !== null
|
||||
? "enabled"
|
||||
: "other";
|
||||
trackAutomationTriggerUpdated({
|
||||
automation_id: vars.automationId,
|
||||
trigger_id: vars.triggerId,
|
||||
change,
|
||||
enabled: vars.patch.enabled ?? undefined,
|
||||
});
|
||||
// automation_trigger_updated is now emitted server-side (TriggerService.update).
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error updating trigger:", error);
|
||||
|
|
@ -206,10 +154,7 @@ export const removeTriggerMutationAtom = atomWithMutation(() => ({
|
|||
onSuccess: (vars) => {
|
||||
invalidateDetail(vars.automationId);
|
||||
toast.success("Trigger removed");
|
||||
trackAutomationTriggerRemoved({
|
||||
automation_id: vars.automationId,
|
||||
trigger_id: vars.triggerId,
|
||||
});
|
||||
// automation_trigger_removed is now emitted server-side (TriggerService.remove).
|
||||
},
|
||||
onError: (error: Error, vars) => {
|
||||
console.error("Error removing trigger:", error);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,349 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LayoutGrid,
|
||||
Settings2,
|
||||
TriangleAlert,
|
||||
Unplug,
|
||||
Upload,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { type ReactNode, useCallback, useRef, useState } from "react";
|
||||
import type { ConnectorRow } from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import {
|
||||
CONNECTOR_TOOL_ICON_PATHS,
|
||||
getToolDisplayName,
|
||||
getToolIcon,
|
||||
} from "@/contracts/enums/toolIcons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Minimal shape of a grouped agent-tool section (mirrors thread.tsx). */
|
||||
export interface ToolGroupView {
|
||||
label: string;
|
||||
tools: { name: string }[];
|
||||
connectorIcon?: string;
|
||||
}
|
||||
|
||||
interface ComposerAddMenuDrawerProps {
|
||||
/** The `+` button; rendered as the drawer trigger. */
|
||||
trigger: ReactNode;
|
||||
onUploadFiles: () => void;
|
||||
/** Connected connectors: one row per type, with live indexing health (`useConnectorRows`). */
|
||||
connectorRows: ConnectorRow[];
|
||||
/** Open a connector's manage view (deep-links via importConnectorRequestAtom). */
|
||||
onSelectConnector: (row: ConnectorRow) => void;
|
||||
/** Navigate to the full connectors catalog. */
|
||||
onBrowseConnectors: () => void;
|
||||
regularToolGroups: ToolGroupView[];
|
||||
connectorToolGroups: ToolGroupView[];
|
||||
otherToolGroup?: ToolGroupView;
|
||||
disabledToolsSet: Set<string>;
|
||||
onToggleTool: (name: string) => void;
|
||||
onToggleToolGroup: (names: string[]) => void;
|
||||
/** True while the tool list is still loading (shows a skeleton). */
|
||||
toolsLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile "+" menu. A single vaul drawer that behaves like a flat list at the
|
||||
* root and drills into submenus in place (each level replaces the previous,
|
||||
* with a back button) rather than nesting overlays — the touch-friendly
|
||||
* equivalent of the desktop dropdown submenus. Screen state is a small push/pop
|
||||
* stack; closing the drawer resets it to the root.
|
||||
*/
|
||||
type Screen =
|
||||
| { kind: "root" }
|
||||
| { kind: "connectors" }
|
||||
| { kind: "tools" }
|
||||
| { kind: "toolGroup"; label: string };
|
||||
|
||||
const ROW = "flex w-full items-center gap-3 px-4 py-3 text-sm hover:bg-accent hover:text-accent-foreground transition-colors";
|
||||
|
||||
export function ComposerAddMenuDrawer({
|
||||
trigger,
|
||||
onUploadFiles,
|
||||
connectorRows,
|
||||
onSelectConnector,
|
||||
onBrowseConnectors,
|
||||
regularToolGroups,
|
||||
connectorToolGroups,
|
||||
otherToolGroup,
|
||||
disabledToolsSet,
|
||||
onToggleTool,
|
||||
onToggleToolGroup,
|
||||
toolsLoading,
|
||||
}: ComposerAddMenuDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [stack, setStack] = useState<Screen[]>([{ kind: "root" }]);
|
||||
// Slide direction: forward on push, back on pop — drives the enter animation.
|
||||
const dirRef = useRef<"forward" | "back">("forward");
|
||||
const current = stack[stack.length - 1];
|
||||
|
||||
const push = useCallback((screen: Screen) => {
|
||||
dirRef.current = "forward";
|
||||
setStack((prev) => [...prev, screen]);
|
||||
}, []);
|
||||
const pop = useCallback(() => {
|
||||
dirRef.current = "back";
|
||||
setStack((prev) => (prev.length > 1 ? prev.slice(0, -1) : prev));
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
// Reset to root when closed so the next open starts fresh.
|
||||
if (!next) {
|
||||
dirRef.current = "forward";
|
||||
setStack([{ kind: "root" }]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => handleOpenChange(false), [handleOpenChange]);
|
||||
|
||||
const title =
|
||||
current.kind === "connectors"
|
||||
? "MCP Connectors"
|
||||
: current.kind === "tools"
|
||||
? "Manage Tools"
|
||||
: current.kind === "toolGroup"
|
||||
? current.label
|
||||
: "Add";
|
||||
|
||||
const renderToolRow = (name: string) => {
|
||||
const isDisabled = disabledToolsSet.has(name);
|
||||
const ToolIcon = getToolIcon(name);
|
||||
return (
|
||||
<div key={name} className={ROW}>
|
||||
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{getToolDisplayName(name)}</span>
|
||||
<Switch
|
||||
checked={!isDisabled}
|
||||
onCheckedChange={() => onToggleTool(name)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
if (current.kind === "root") {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={ROW}
|
||||
onClick={() => {
|
||||
onUploadFiles();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">Upload Files</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={ROW}
|
||||
onClick={() => push({ kind: "connectors" })}
|
||||
>
|
||||
<Unplug className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">MCP Connectors</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
<button type="button" className={ROW} onClick={() => push({ kind: "tools" })}>
|
||||
<Settings2 className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">Manage Tools</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (current.kind === "connectors") {
|
||||
return (
|
||||
<>
|
||||
{connectorRows.length === 0 ? (
|
||||
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No connectors yet.
|
||||
</p>
|
||||
) : (
|
||||
connectorRows.map((row) => (
|
||||
<button
|
||||
type="button"
|
||||
key={row.type}
|
||||
className={ROW}
|
||||
onClick={() => {
|
||||
onSelectConnector(row);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
{getConnectorIcon(row.type, "size-4 shrink-0 text-muted-foreground")}
|
||||
<span className="min-w-0 flex-1 truncate text-left">{row.title}</span>
|
||||
{row.health === "syncing" ? (
|
||||
<Spinner size="xs" className="shrink-0" />
|
||||
) : row.health === "failed" ? (
|
||||
<TriangleAlert
|
||||
className="size-4 shrink-0 text-destructive"
|
||||
aria-label={row.errorMessage ?? "Indexing failed"}
|
||||
/>
|
||||
) : row.accountCount > 1 ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{row.accountCount}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<Separator className="my-1" />
|
||||
<button
|
||||
type="button"
|
||||
className={ROW}
|
||||
onClick={() => {
|
||||
onBrowseConnectors();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<LayoutGrid className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">Manage connectors</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (current.kind === "toolGroup") {
|
||||
const group = connectorToolGroups.find((g) => g.label === current.label);
|
||||
return <>{group?.tools.map((t) => renderToolRow(t.name))}</>;
|
||||
}
|
||||
|
||||
// current.kind === "tools"
|
||||
if (toolsLoading) {
|
||||
return (
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<Skeleton className="h-3 w-16 mb-2" />
|
||||
{["t1", "t2", "t3", "t4"].map((k) => (
|
||||
<div key={k} className="flex items-center gap-3 py-2">
|
||||
<Skeleton className="size-4 rounded shrink-0" />
|
||||
<Skeleton className="h-3.5 flex-1" />
|
||||
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{regularToolGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.tools.map((t) => renderToolRow(t.name))}
|
||||
</div>
|
||||
))}
|
||||
{connectorToolGroups.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
|
||||
Connector Actions
|
||||
</div>
|
||||
{connectorToolGroups.map((group) => {
|
||||
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[group.connectorIcon ?? ""];
|
||||
const toolNames = group.tools.map((t) => t.name);
|
||||
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
|
||||
return (
|
||||
<div key={group.label} className={ROW}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3"
|
||||
onClick={() => push({ kind: "toolGroup", label: group.label })}
|
||||
>
|
||||
{iconInfo ? (
|
||||
<Image
|
||||
src={iconInfo.src}
|
||||
alt={iconInfo.alt}
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-[18px] shrink-0 select-none pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<Wrench className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-left font-medium">
|
||||
{group.label}
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
<Switch
|
||||
checked={!allDisabled}
|
||||
onCheckedChange={() => onToggleToolGroup(toolNames)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{otherToolGroup && (
|
||||
<div>
|
||||
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground font-semibold select-none">
|
||||
{otherToolGroup.label}
|
||||
</div>
|
||||
{otherToolGroup.tools.map((t) => renderToolRow(t.name))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange} shouldScaleBackground={false}>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
|
||||
<DrawerHandle />
|
||||
<DrawerHeader className="flex flex-row items-center gap-1 px-2 pb-2 pt-1">
|
||||
{stack.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-9 shrink-0"
|
||||
onClick={pop}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</Button>
|
||||
) : (
|
||||
<span className="size-9 shrink-0" aria-hidden />
|
||||
)}
|
||||
<DrawerTitle className="flex-1 text-center text-base font-semibold">{title}</DrawerTitle>
|
||||
<span className="size-9 shrink-0" aria-hidden />
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin pb-6">
|
||||
<div
|
||||
key={current.kind === "toolGroup" ? `toolGroup:${current.label}` : current.kind}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 duration-200",
|
||||
dirRef.current === "forward" ? "slide-in-from-right-4" : "slide-in-from-left-4"
|
||||
)}
|
||||
>
|
||||
{renderBody()}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,388 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
|
||||
import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker";
|
||||
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
|
||||
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
||||
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
|
||||
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
|
||||
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
OAUTH_CONNECTORS,
|
||||
} from "./connector-popup/constants/connector-constants";
|
||||
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
|
||||
import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
|
||||
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
|
||||
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
||||
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
||||
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
||||
|
||||
export interface ConnectorIndicatorHandle {
|
||||
open: () => void;
|
||||
}
|
||||
|
||||
interface ConnectorIndicatorProps {
|
||||
showTrigger?: boolean;
|
||||
}
|
||||
|
||||
export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, ConnectorIndicatorProps>(
|
||||
(_props, ref) => {
|
||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
|
||||
// Real-time document type counts via Zero (updates instantly as docs are indexed)
|
||||
const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
|
||||
// Read status inbox items from shared atom (populated by LayoutDataProvider)
|
||||
// instead of creating a duplicate useInbox("status") hook.
|
||||
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
|
||||
const inboxItems = useMemo(
|
||||
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
|
||||
[statusInboxItems]
|
||||
);
|
||||
|
||||
// Use the custom hook for dialog state management
|
||||
const {
|
||||
isOpen,
|
||||
activeTab,
|
||||
connectingId,
|
||||
isScrolled,
|
||||
searchQuery,
|
||||
indexingConfig,
|
||||
indexingConnector,
|
||||
indexingConnectorConfig,
|
||||
editingConnector,
|
||||
connectingConnectorType,
|
||||
isCreatingConnector,
|
||||
startDate,
|
||||
endDate,
|
||||
isStartingIndexing,
|
||||
isSaving,
|
||||
isDisconnecting,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableVisionLlm,
|
||||
allConnectors,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
isYouTubeView,
|
||||
isFromOAuth,
|
||||
setSearchQuery,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
setPeriodicEnabled,
|
||||
setFrequencyMinutes,
|
||||
setEnableVisionLlm,
|
||||
handleOpenChange,
|
||||
handleTabChange,
|
||||
handleScroll,
|
||||
handleConnectOAuth,
|
||||
handleConnectNonOAuth,
|
||||
handleCreateWebcrawler,
|
||||
handleCreateYouTubeCrawler,
|
||||
handleSubmitConnectForm,
|
||||
handleStartIndexing,
|
||||
handleSkipIndexing,
|
||||
handleStartEdit,
|
||||
handleSaveConnector,
|
||||
handleDisconnectConnector,
|
||||
handleBackFromEdit,
|
||||
handleBackFromConnect,
|
||||
handleBackFromYouTube,
|
||||
handleViewAccountsList,
|
||||
handleBackFromAccountsList,
|
||||
handleBackFromMCPList,
|
||||
handleAddNewMCPFromList,
|
||||
handleQuickIndexConnector,
|
||||
connectorConfig,
|
||||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
setConnectorName,
|
||||
} = useConnectorDialog();
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
const onOpen = () => setPickerOpen(true);
|
||||
const onClose = () => setPickerOpen(false);
|
||||
window.addEventListener(PICKER_OPEN_EVENT, onOpen);
|
||||
window.addEventListener(PICKER_CLOSE_EVENT, onClose);
|
||||
return () => {
|
||||
window.removeEventListener(PICKER_OPEN_EVENT, onOpen);
|
||||
window.removeEventListener(PICKER_CLOSE_EVENT, onClose);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
connectors: connectorsFromSync = [],
|
||||
loading: connectorsLoading,
|
||||
error: connectorsError,
|
||||
refreshConnectors: refreshConnectorsSync,
|
||||
} = useConnectorsSync(workspaceId);
|
||||
|
||||
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
|
||||
const connectors = useSyncData ? connectorsFromSync : allConnectors || [];
|
||||
|
||||
const refreshConnectors = async () => {
|
||||
if (useSyncData) {
|
||||
await refreshConnectorsSync();
|
||||
}
|
||||
};
|
||||
|
||||
// Track indexing state locally - clears automatically when last_indexed_at changes via real-time sync
|
||||
// Also clears when failed notifications are detected
|
||||
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
|
||||
connectors as SearchSourceConnector[],
|
||||
inboxItems
|
||||
);
|
||||
|
||||
// Get document types that have documents in the workspace
|
||||
const activeDocumentTypes = documentTypeCounts
|
||||
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
|
||||
: [];
|
||||
|
||||
const hasConnectors = connectors.length > 0;
|
||||
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
|
||||
const totalSourceCount = connectors.length + activeDocumentTypes.length;
|
||||
|
||||
const activeConnectorsCount = connectors.length;
|
||||
|
||||
// Check which connectors are already connected
|
||||
// Real-time connector updates via Zero sync
|
||||
const connectedTypes = new Set<string>(
|
||||
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => handleOpenChange(true),
|
||||
}));
|
||||
|
||||
if (!workspaceId) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} modal={false} onOpenChange={handleOpenChange}>
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
|
||||
aria-hidden="true"
|
||||
onClick={() => {
|
||||
if (!pickerOpen) handleOpenChange(false);
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
<DialogContent
|
||||
onFocusOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => {
|
||||
if (pickerOpen) e.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
if (pickerOpen) e.preventDefault();
|
||||
}}
|
||||
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden ring-0 dark:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 [&>button]:hover:opacity-100 [&>button]:hover:bg-accent [&>button]:hover:text-accent-foreground [&>button>svg]:size-5 select-none"
|
||||
>
|
||||
<DialogTitle className="sr-only">MCP Connectors</DialogTitle>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && workspaceId ? (
|
||||
<YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />
|
||||
) : viewingMCPList ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType="MCP_CONNECTOR"
|
||||
connectorTitle="MCP Connectors"
|
||||
connectors={(allConnectors || []) as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromMCPList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={handleAddNewMCPFromList}
|
||||
addButtonText="Add New MCP Server"
|
||||
/>
|
||||
) : viewingAccountsType ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType={viewingAccountsType.connectorType}
|
||||
connectorTitle={viewingAccountsType.connectorTitle}
|
||||
connectors={(connectors || []) as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromAccountsList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={() => {
|
||||
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
) ||
|
||||
COMPOSIO_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
);
|
||||
if (oauthConnector) {
|
||||
handleConnectOAuth(oauthConnector);
|
||||
}
|
||||
}}
|
||||
isConnecting={connectingId !== null}
|
||||
/>
|
||||
) : connectingConnectorType ? (
|
||||
<ConnectorConnectView
|
||||
connectorType={connectingConnectorType}
|
||||
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
|
||||
onBack={handleBackFromConnect}
|
||||
isSubmitting={isCreatingConnector}
|
||||
/>
|
||||
) : editingConnector ? (
|
||||
<ConnectorEditView
|
||||
connector={{
|
||||
...editingConnector,
|
||||
config: connectorConfig || editingConnector.config,
|
||||
name: editingConnector.name,
|
||||
// Sync last_indexed_at with live data from real-time sync
|
||||
last_indexed_at:
|
||||
(connectors as SearchSourceConnector[]).find((c) => c.id === editingConnector.id)
|
||||
?.last_indexed_at ?? editingConnector.last_indexed_at,
|
||||
}}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableVisionLlm={enableVisionLlm}
|
||||
isSaving={isSaving}
|
||||
isDisconnecting={isDisconnecting}
|
||||
isIndexing={indexingConnectorIds.has(editingConnector.id)}
|
||||
workspaceId={workspaceId?.toString()}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableVisionLlmChange={setEnableVisionLlm}
|
||||
onSave={() => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleSaveConnector(() => refreshConnectors());
|
||||
}}
|
||||
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
|
||||
onBack={handleBackFromEdit}
|
||||
onQuickIndex={(() => {
|
||||
const cfg = connectorConfig || editingConnector.config;
|
||||
const isDriveOrOneDrive =
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
|
||||
const hasDriveItems = isDriveOrOneDrive
|
||||
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
|
||||
((cfg?.selected_files as unknown[]) ?? []).length > 0
|
||||
: true;
|
||||
if (!hasDriveItems) return undefined;
|
||||
return () => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleQuickIndexConnector(
|
||||
editingConnector.id,
|
||||
editingConnector.connector_type,
|
||||
stopIndexing,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
};
|
||||
})()}
|
||||
onConfigChange={setConnectorConfig}
|
||||
onNameChange={setConnectorName}
|
||||
/>
|
||||
) : indexingConfig ? (
|
||||
<IndexingConfigurationView
|
||||
config={indexingConfig}
|
||||
connector={
|
||||
indexingConnector
|
||||
? {
|
||||
...indexingConnector,
|
||||
config: indexingConnectorConfig || indexingConnector.config,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableVisionLlm={enableVisionLlm}
|
||||
isStartingIndexing={isStartingIndexing}
|
||||
isFromOAuth={isFromOAuth}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableVisionLlmChange={setEnableVisionLlm}
|
||||
onConfigChange={setIndexingConnectorConfig}
|
||||
onStartIndexing={() => {
|
||||
if (indexingConfig.connectorId) {
|
||||
startIndexing(indexingConfig.connectorId);
|
||||
}
|
||||
handleStartIndexing(() => refreshConnectors());
|
||||
}}
|
||||
onSkip={handleSkipIndexing}
|
||||
/>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
{/* Header */}
|
||||
<ConnectorDialogHeader
|
||||
activeTab={activeTab}
|
||||
totalSourceCount={activeConnectorsCount}
|
||||
searchQuery={searchQuery}
|
||||
onTabChange={handleTabChange}
|
||||
onSearchChange={setSearchQuery}
|
||||
isScrolled={isScrolled}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div className="h-full overflow-y-auto" onScroll={handleScroll}>
|
||||
<div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
|
||||
<TabsContent value="all" className="m-0">
|
||||
<AllConnectorsTab
|
||||
searchQuery={searchQuery}
|
||||
workspaceId={workspaceId}
|
||||
connectedTypes={connectedTypes}
|
||||
connectingId={connectingId}
|
||||
allConnectors={connectors}
|
||||
documentTypeCounts={documentTypeCounts}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onConnectOAuth={handleConnectOAuth}
|
||||
onConnectNonOAuth={handleConnectNonOAuth}
|
||||
onCreateWebcrawler={handleCreateWebcrawler}
|
||||
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<ActiveConnectorsTab
|
||||
searchQuery={searchQuery}
|
||||
hasSources={hasSources}
|
||||
totalSourceCount={totalSourceCount}
|
||||
activeDocumentTypes={activeDocumentTypes}
|
||||
connectors={connectors as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onTabChange={handleTabChange}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom fade shadow */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-7 bg-linear-to-t from-popover via-popover/80 to-transparent pointer-events-none z-10" />
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ConnectorIndicator.displayName = "ConnectorIndicator";
|
||||
|
|
@ -23,7 +23,6 @@ interface ConnectorCardProps {
|
|||
accountCount?: number;
|
||||
connectorCount?: number;
|
||||
isIndexing?: boolean;
|
||||
deprecated?: boolean;
|
||||
onConnect?: () => void;
|
||||
onManage?: () => void;
|
||||
}
|
||||
|
|
@ -53,15 +52,11 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
accountCount,
|
||||
connectorCount,
|
||||
isIndexing = false,
|
||||
deprecated = false,
|
||||
onConnect,
|
||||
onManage,
|
||||
}) => {
|
||||
const isMCP = connectorType === EnumConnectorName.MCP_CONNECTOR;
|
||||
const isLive = !!connectorType && LIVE_CONNECTOR_TYPES.has(connectorType);
|
||||
// Deprecated connectors can no longer be connected, but existing rows stay
|
||||
// manageable (so users can disconnect them).
|
||||
const isDeprecatedForConnect = deprecated && !isConnected;
|
||||
// Get connector status
|
||||
const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } =
|
||||
useConnectorStatus();
|
||||
|
|
@ -78,10 +73,6 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
return null;
|
||||
}
|
||||
|
||||
if (isDeprecatedForConnect) {
|
||||
return "Deprecated. No longer available to connect.";
|
||||
}
|
||||
|
||||
return description;
|
||||
};
|
||||
|
||||
|
|
@ -102,16 +93,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
|
||||
)}
|
||||
>
|
||||
{isMCP ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="size-6 bg-current"
|
||||
style={{
|
||||
mask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
|
||||
WebkitMask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
|
||||
}}
|
||||
/>
|
||||
) : connectorType ? (
|
||||
{connectorType ? (
|
||||
getConnectorIcon(connectorType, "size-6")
|
||||
) : id === "youtube-crawler" ? (
|
||||
<IconBrandYoutube className="size-6" />
|
||||
|
|
@ -169,20 +151,18 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
!isConnected && "shadow-xs"
|
||||
)}
|
||||
onClick={isConnected ? onManage : onConnect}
|
||||
disabled={isConnecting || !isEnabled || isDeprecatedForConnect}
|
||||
disabled={isConnecting || !isEnabled}
|
||||
>
|
||||
<span className={isConnecting ? "opacity-0" : ""}>
|
||||
{isDeprecatedForConnect
|
||||
? "Deprecated"
|
||||
: !isEnabled
|
||||
? "Unavailable"
|
||||
: isConnected
|
||||
? "Manage"
|
||||
: id === "youtube-crawler"
|
||||
? "Add"
|
||||
: connectorType
|
||||
? "Connect"
|
||||
: "Add"}
|
||||
{!isEnabled
|
||||
? "Unavailable"
|
||||
: isConnected
|
||||
? "Manage"
|
||||
: id === "youtube-crawler"
|
||||
? "Add"
|
||||
: connectorType
|
||||
? "Connect"
|
||||
: "Add"}
|
||||
</span>
|
||||
{isConnecting && <Spinner size="xs" className="absolute" />}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Server className="h-4 w-4 shrink-0" />
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate
|
||||
Connect to a Model Context Protocol server. Each MCP server is added as a separate
|
||||
connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
@ -275,8 +275,8 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
<div className="mt-3 pt-3 border-t border-green-500/20">
|
||||
<p className="font-semibold mb-2">Available tools:</p>
|
||||
<ul className="list-disc list-inside text-xs space-y-0.5">
|
||||
{testResult.tools.map((tool, i) => (
|
||||
<li key={i}>{tool.name}</li>
|
||||
{testResult.tools.map((tool) => (
|
||||
<li key={tool.name}>{tool.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
interface ComposioCalendarConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ComposioCalendarConfig: FC<ComposioCalendarConfigProps> = () => {
|
||||
return <div className="space-y-6" />;
|
||||
};
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
interface ComposioGmailConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ComposioGmailConfig: FC<ComposioGmailConfigProps> = () => {
|
||||
return <div className="space-y-6" />;
|
||||
};
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
CRAWLERS,
|
||||
OAUTH_CONNECTORS,
|
||||
OTHER_CONNECTORS,
|
||||
} from "../../constants/connector-constants";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
// Catalog descriptions keyed by connector type, reused as the capability line so
|
||||
// the copy stays in sync with the catalog cards.
|
||||
const DESCRIPTION_BY_TYPE = new Map<string, string>(
|
||||
[...OAUTH_CONNECTORS, ...COMPOSIO_CONNECTORS, ...OTHER_CONNECTORS, ...CRAWLERS].map((c) => [
|
||||
c.connectorType,
|
||||
c.description,
|
||||
])
|
||||
);
|
||||
|
||||
/**
|
||||
* Fallback manage view for live connectors that are neither MCP-backed nor have
|
||||
* a dedicated config component (native/Composio Gmail & Calendar). There is
|
||||
* nothing to configure, so we just confirm the connection and echo what the
|
||||
* agent can do — no Trusted Tools (that feature is MCP-only, see the backend
|
||||
* `_ensure_mcp_connector_for_user`).
|
||||
*/
|
||||
export const LiveConnectorConnectedCard: FC<ConnectorConfigProps> = ({ connector }) => {
|
||||
const capability =
|
||||
DESCRIPTION_BY_TYPE.get(connector.connector_type) ??
|
||||
`connect to ${getConnectorTypeDisplay(connector.connector_type)}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-border bg-emerald-500/5 p-4 flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10 shrink-0 mt-0.5">
|
||||
<CheckCircle2 className="size-4 text-emerald-500" />
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm">
|
||||
<p className="font-medium text-xs sm:text-sm">Connected</p>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">{capability}.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -55,12 +55,8 @@ const configMap: Record<string, () => Promise<{ default: FC<ConnectorConfigProps
|
|||
import("./components/obsidian-config").then((m) => ({ default: m.ObsidianConfig })),
|
||||
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: () =>
|
||||
import("./components/composio-drive-config").then((m) => ({ default: m.ComposioDriveConfig })),
|
||||
COMPOSIO_GMAIL_CONNECTOR: () =>
|
||||
import("./components/composio-gmail-config").then((m) => ({ default: m.ComposioGmailConfig })),
|
||||
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: () =>
|
||||
import("./components/composio-calendar-config").then((m) => ({
|
||||
default: m.ComposioCalendarConfig,
|
||||
})),
|
||||
// Composio Gmail/Calendar have nothing to configure; the edit view falls back
|
||||
// to LiveConnectorConnectedCard for live connectors without a config here.
|
||||
};
|
||||
|
||||
const componentCache = new Map<string, ConnectorConfigComponent>();
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
|||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
|
|
@ -123,7 +123,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
|||
{/* Form Content - Scrollable */}
|
||||
<div
|
||||
ref={formContainerRef}
|
||||
className="connector-connect-form-root flex-1 min-h-0 overflow-y-auto px-6 sm:px-12"
|
||||
className="connector-connect-form-root flex-1 min-h-0 overflow-y-auto"
|
||||
>
|
||||
<ConnectFormComponent
|
||||
onSubmit={onSubmit}
|
||||
|
|
@ -134,15 +134,15 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover">
|
||||
<Button
|
||||
<div className="flex-shrink-0 flex items-center justify-end py-6 bg-transparent">
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={isSubmitting}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Button> */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleFormSubmit}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
|||
import { VisionLLMConfig } from "../../components/vision-llm-config";
|
||||
import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
|
||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
import { LiveConnectorConnectedCard } from "../components/live-connector-connected-card";
|
||||
import { MCPServiceConfig } from "../components/mcp-service-config";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
|
|
@ -124,8 +125,13 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
// Get connector-specific config component (MCP-backed connectors use a generic view)
|
||||
const ConnectorConfigComponent = useMemo(() => {
|
||||
if (isMCPBacked) return MCPServiceConfig;
|
||||
return getConnectorConfigComponent(connector.connector_type);
|
||||
}, [connector.connector_type, isMCPBacked]);
|
||||
// Live connectors without a dedicated config (native/Composio Gmail &
|
||||
// Calendar) fall back to a simple "Connected" card instead of a blank body.
|
||||
return (
|
||||
getConnectorConfigComponent(connector.connector_type) ??
|
||||
(isLive ? LiveConnectorConnectedCard : null)
|
||||
);
|
||||
}, [connector.connector_type, isMCPBacked, isLive]);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [hasMoreContent, setHasMoreContent] = useState(false);
|
||||
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
|
||||
|
|
@ -201,7 +207,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
{/* Fixed Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
|
||||
"flex-shrink-0 transition-shadow duration-200 relative z-10",
|
||||
isScrolled && "shadow-sm"
|
||||
)}
|
||||
>
|
||||
|
|
@ -262,7 +268,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full overflow-y-auto px-6 sm:px-12"
|
||||
className="h-full overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="space-y-6 pb-6 pt-2">
|
||||
|
|
@ -362,7 +368,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 px-6 sm:px-12 py-6 sm:py-6 bg-popover">
|
||||
<div className="flex-shrink-0 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 sm:gap-0 py-6 sm:py-6 bg-transparent">
|
||||
{showDisconnectConfirm ? (
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
|
||||
<span className="text-xs sm:text-sm text-muted-foreground sm:whitespace-nowrap">
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
{/* Fixed Header */}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
|
||||
"shrink-0 transition-shadow duration-200 relative z-10",
|
||||
isScrolled && "shadow-sm"
|
||||
)}
|
||||
>
|
||||
|
|
@ -166,7 +166,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full overflow-y-auto px-6 sm:px-12"
|
||||
className="h-full overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div className="space-y-6 pb-6 pt-2">
|
||||
|
|
@ -240,7 +240,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="flex-shrink-0 flex items-center justify-end px-6 sm:px-12 py-6 bg-popover">
|
||||
<div className="flex-shrink-0 flex items-center justify-end py-6 bg-transparent">
|
||||
{isLive ? (
|
||||
<Button onClick={onSkip} className="text-xs sm:text-sm">
|
||||
Done
|
||||
|
|
|
|||
|
|
@ -0,0 +1,483 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ChevronRight, LayoutGrid, Search, TriangleAlert, X } from "lucide-react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "@/components/ui/drawer";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { useConnectorsSync } from "@/hooks/use-connectors-sync";
|
||||
import { useZeroDocumentTypeCounts } from "@/hooks/use-zero-document-type-counts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConnectorConnectView } from "./connector-configs/views/connector-connect-view";
|
||||
import { ConnectorEditView } from "./connector-configs/views/connector-edit-view";
|
||||
import { IndexingConfigurationView } from "./connector-configs/views/indexing-configuration-view";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
getConnectorTitle,
|
||||
OAUTH_CONNECTORS,
|
||||
} from "./constants/connector-constants";
|
||||
import { type ConnectorRow, useConnectorRows } from "./hooks/use-connector-rows";
|
||||
import { useConnectorDialog } from "./hooks/use-connector-dialog";
|
||||
import { AllConnectorsTab } from "./tabs/all-connectors-tab";
|
||||
import { ConnectorAccountsListView } from "./views/connector-accounts-list-view";
|
||||
import { YouTubeCrawlerView } from "./views/youtube-crawler-view";
|
||||
|
||||
/**
|
||||
* Connector management surface (single `/connectors` route) rendered inside the
|
||||
* workspace panel. Stateful master–detail:
|
||||
* - sub-rail: Overview + a flat "Your connectors" list (only what's connected,
|
||||
* each with a live status glyph) + an "Add MCP server" action;
|
||||
* - detail pane: the flat catalog (search + cards) OR one of the existing flow
|
||||
* views (connect / edit / indexing / accounts / YouTube), reused verbatim
|
||||
* from the former dialog.
|
||||
*
|
||||
* Unconnected connectors are one click from the catalog cards, so they never
|
||||
* clutter the rail. The hook's internal `isOpen`/`connectorDialogOpenAtom` is
|
||||
* inert on a route and ignored.
|
||||
*/
|
||||
export function ConnectorsSection() {
|
||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
const documentTypeCounts = useZeroDocumentTypeCounts(workspaceId);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const {
|
||||
connectingId,
|
||||
searchQuery,
|
||||
indexingConfig,
|
||||
indexingConnector,
|
||||
indexingConnectorConfig,
|
||||
editingConnector,
|
||||
connectingConnectorType,
|
||||
isCreatingConnector,
|
||||
startDate,
|
||||
endDate,
|
||||
isStartingIndexing,
|
||||
isSaving,
|
||||
isDisconnecting,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
enableVisionLlm,
|
||||
allConnectors,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
isYouTubeView,
|
||||
isFromOAuth,
|
||||
setSearchQuery,
|
||||
setStartDate,
|
||||
setEndDate,
|
||||
setPeriodicEnabled,
|
||||
setFrequencyMinutes,
|
||||
setEnableVisionLlm,
|
||||
handleOpenChange,
|
||||
handleConnectOAuth,
|
||||
handleConnectNonOAuth,
|
||||
handleCreateWebcrawler,
|
||||
handleCreateYouTubeCrawler,
|
||||
handleSubmitConnectForm,
|
||||
handleStartIndexing,
|
||||
handleSkipIndexing,
|
||||
handleStartEdit,
|
||||
handleSaveConnector,
|
||||
handleDisconnectConnector,
|
||||
handleBackFromEdit,
|
||||
handleBackFromConnect,
|
||||
handleBackFromYouTube,
|
||||
handleViewAccountsList,
|
||||
handleBackFromAccountsList,
|
||||
handleViewMCPList,
|
||||
handleBackFromMCPList,
|
||||
handleAddNewMCPFromList,
|
||||
handleQuickIndexConnector,
|
||||
connectorConfig,
|
||||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
setConnectorName,
|
||||
} = useConnectorDialog();
|
||||
|
||||
const {
|
||||
connectors: connectorsFromSync = [],
|
||||
loading: connectorsLoading,
|
||||
error: connectorsError,
|
||||
refreshConnectors: refreshConnectorsSync,
|
||||
} = useConnectorsSync(workspaceId);
|
||||
|
||||
const useSyncData = connectorsFromSync.length > 0 || (connectorsLoading && !connectorsError);
|
||||
const connectors = (useSyncData ? connectorsFromSync : allConnectors || []) as SearchSourceConnector[];
|
||||
|
||||
const refreshConnectors = async () => {
|
||||
if (useSyncData) {
|
||||
await refreshConnectorsSync();
|
||||
}
|
||||
};
|
||||
|
||||
// "Your connectors" rows with live indexing health, plus the shared indexing
|
||||
// controls the flow views reuse (single source of truth via useConnectorRows).
|
||||
const {
|
||||
rows: connectedRows,
|
||||
indexingConnectorIds,
|
||||
startIndexing,
|
||||
stopIndexing,
|
||||
} = useConnectorRows(connectors);
|
||||
|
||||
const connectedTypes = useMemo(
|
||||
() => new Set<string>(connectors.map((c) => c.connector_type)),
|
||||
[connectors]
|
||||
);
|
||||
|
||||
if (!workspaceId) return null;
|
||||
|
||||
const activeConnectorType = editingConnector
|
||||
? editingConnector.connector_type
|
||||
: connectingConnectorType ||
|
||||
viewingAccountsType?.connectorType ||
|
||||
(viewingMCPList ? EnumConnectorName.MCP_CONNECTOR : null);
|
||||
|
||||
const flowView = ((): ReactNode => {
|
||||
if (isYouTubeView) {
|
||||
return <YouTubeCrawlerView workspaceId={workspaceId} onBack={handleBackFromYouTube} />;
|
||||
}
|
||||
if (viewingMCPList) {
|
||||
return (
|
||||
<ConnectorAccountsListView
|
||||
connectorType="MCP_CONNECTOR"
|
||||
connectorTitle="MCP Connectors"
|
||||
connectors={(allConnectors || []) as SearchSourceConnector[]}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromMCPList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={handleAddNewMCPFromList}
|
||||
addButtonText="Add New MCP Server"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (viewingAccountsType) {
|
||||
return (
|
||||
<ConnectorAccountsListView
|
||||
connectorType={viewingAccountsType.connectorType}
|
||||
connectorTitle={viewingAccountsType.connectorTitle}
|
||||
connectors={connectors}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onBack={handleBackFromAccountsList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={() => {
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
) ||
|
||||
COMPOSIO_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
);
|
||||
if (oauthConnector) {
|
||||
handleConnectOAuth(oauthConnector);
|
||||
}
|
||||
}}
|
||||
isConnecting={connectingId !== null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (connectingConnectorType) {
|
||||
return (
|
||||
<ConnectorConnectView
|
||||
connectorType={connectingConnectorType}
|
||||
onSubmit={(formData) => handleSubmitConnectForm(formData, startIndexing)}
|
||||
onBack={handleBackFromConnect}
|
||||
isSubmitting={isCreatingConnector}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (editingConnector) {
|
||||
return (
|
||||
<ConnectorEditView
|
||||
connector={{
|
||||
...editingConnector,
|
||||
config: connectorConfig || editingConnector.config,
|
||||
name: editingConnector.name,
|
||||
last_indexed_at:
|
||||
connectors.find((c) => c.id === editingConnector.id)?.last_indexed_at ??
|
||||
editingConnector.last_indexed_at,
|
||||
}}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableVisionLlm={enableVisionLlm}
|
||||
isSaving={isSaving}
|
||||
isDisconnecting={isDisconnecting}
|
||||
isIndexing={indexingConnectorIds.has(editingConnector.id)}
|
||||
workspaceId={workspaceId?.toString()}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableVisionLlmChange={setEnableVisionLlm}
|
||||
onSave={() => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleSaveConnector(() => refreshConnectors());
|
||||
}}
|
||||
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
|
||||
onBack={handleBackFromEdit}
|
||||
onQuickIndex={(() => {
|
||||
const cfg = connectorConfig || editingConnector.config;
|
||||
const isDriveOrOneDrive =
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "ONEDRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "DROPBOX_CONNECTOR";
|
||||
const hasDriveItems = isDriveOrOneDrive
|
||||
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
|
||||
((cfg?.selected_files as unknown[]) ?? []).length > 0
|
||||
: true;
|
||||
if (!hasDriveItems) return undefined;
|
||||
return () => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleQuickIndexConnector(
|
||||
editingConnector.id,
|
||||
editingConnector.connector_type,
|
||||
stopIndexing,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
};
|
||||
})()}
|
||||
onConfigChange={setConnectorConfig}
|
||||
onNameChange={setConnectorName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (indexingConfig) {
|
||||
return (
|
||||
<IndexingConfigurationView
|
||||
config={indexingConfig}
|
||||
connector={
|
||||
indexingConnector
|
||||
? {
|
||||
...indexingConnector,
|
||||
config: indexingConnectorConfig || indexingConnector.config,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
periodicEnabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
enableVisionLlm={enableVisionLlm}
|
||||
isStartingIndexing={isStartingIndexing}
|
||||
isFromOAuth={isFromOAuth}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
onPeriodicEnabledChange={setPeriodicEnabled}
|
||||
onFrequencyChange={setFrequencyMinutes}
|
||||
onEnableVisionLlmChange={setEnableVisionLlm}
|
||||
onConfigChange={setIndexingConnectorConfig}
|
||||
onStartIndexing={() => {
|
||||
if (indexingConfig.connectorId) {
|
||||
startIndexing(indexingConfig.connectorId);
|
||||
}
|
||||
handleStartIndexing(() => refreshConnectors());
|
||||
}}
|
||||
onSkip={handleSkipIndexing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const isFlowActive = flowView !== null;
|
||||
|
||||
const detailTitle =
|
||||
isFlowActive && activeConnectorType ? getConnectorTitle(activeConnectorType) : "Overview";
|
||||
|
||||
const hasConnected = connectedRows.length > 0;
|
||||
|
||||
// Rail navigation: clear any open flow first (handleOpenChange(false) is the
|
||||
// hook's full reset), then apply the target management action.
|
||||
const resetFlow = () => handleOpenChange(false);
|
||||
const goOverview = () => {
|
||||
setDrawerOpen(false);
|
||||
resetFlow();
|
||||
};
|
||||
const manageRow = (row: ConnectorRow) => {
|
||||
setDrawerOpen(false);
|
||||
resetFlow();
|
||||
if (row.type === EnumConnectorName.MCP_CONNECTOR) {
|
||||
handleViewMCPList();
|
||||
return;
|
||||
}
|
||||
if (row.accountCount > 1) {
|
||||
handleViewAccountsList(row.type, row.title);
|
||||
return;
|
||||
}
|
||||
handleStartEdit(row.connectors[0]);
|
||||
};
|
||||
const addMcpServer = () => {
|
||||
setDrawerOpen(false);
|
||||
resetFlow();
|
||||
handleConnectNonOAuth?.(EnumConnectorName.MCP_CONNECTOR);
|
||||
};
|
||||
|
||||
const navBtnClass = (active: boolean) =>
|
||||
cn(
|
||||
"inline-flex w-full items-center justify-start gap-3 rounded-md px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150 focus:outline-none",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
);
|
||||
|
||||
const railLabel = (text: string) => (
|
||||
<p className="px-3 pb-1 pt-1 text-xs font-semibold text-muted-foreground">{text}</p>
|
||||
);
|
||||
|
||||
const renderNav = () => (
|
||||
<>
|
||||
<button type="button" onClick={goOverview} className={navBtnClass(!isFlowActive)}>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
<span className="min-w-0 truncate">Overview</span>
|
||||
</button>
|
||||
|
||||
{hasConnected && (
|
||||
<>
|
||||
<Separator className="my-3 bg-border" />
|
||||
{railLabel("Your connectors")}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{connectedRows.map((row) => {
|
||||
const isActive = isFlowActive && activeConnectorType === row.type;
|
||||
return (
|
||||
<button
|
||||
key={row.type}
|
||||
type="button"
|
||||
onClick={() => manageRow(row)}
|
||||
className={cn(
|
||||
"inline-flex w-full items-center justify-start gap-3 rounded-md px-3 py-2 text-left text-sm transition-colors duration-150 focus:outline-none",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="flex size-4 shrink-0 items-center justify-center">
|
||||
{getConnectorIcon(row.type, "size-4")}
|
||||
</span>
|
||||
<span className="min-w-0 truncate">{row.title}</span>
|
||||
{row.health === "syncing" ? (
|
||||
<Spinner size="xs" className="ml-auto shrink-0" />
|
||||
) : row.health === "failed" ? (
|
||||
<TriangleAlert
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 text-destructive"
|
||||
aria-label={row.errorMessage ?? "Indexing failed"}
|
||||
/>
|
||||
) : row.accountCount > 1 ? (
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-muted-foreground">
|
||||
{row.accountCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator className="my-3 bg-border" />
|
||||
<button type="button" onClick={addMcpServer} className={navBtnClass(false)}>
|
||||
{getConnectorIcon(EnumConnectorName.MCP_CONNECTOR, "h-4 w-4")}
|
||||
<span className="min-w-0 truncate">Add MCP server</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
const catalog = (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="w-full">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 h-4.5 w-4.5 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="Search connectors"
|
||||
className="h-10 border-0 bg-muted pl-10 pr-9 text-base shadow-none"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4.5 w-4.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AllConnectorsTab
|
||||
searchQuery={searchQuery}
|
||||
workspaceId={workspaceId}
|
||||
connectedTypes={connectedTypes}
|
||||
connectingId={connectingId}
|
||||
allConnectors={connectors}
|
||||
documentTypeCounts={documentTypeCounts}
|
||||
indexingConnectorIds={indexingConnectorIds}
|
||||
onConnectOAuth={handleConnectOAuth}
|
||||
onConnectNonOAuth={handleConnectNonOAuth}
|
||||
onCreateWebcrawler={handleCreateWebcrawler}
|
||||
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-[min(680px,calc(100vh-5rem))] w-full select-none flex-col gap-6 md:flex-row">
|
||||
<div className="md:w-[220px] md:shrink-0">
|
||||
<h1 className="mb-4 px-1 text-xl font-semibold tracking-tight text-foreground md:text-2xl">
|
||||
Connectors
|
||||
</h1>
|
||||
<nav className="hidden flex-col gap-0.5 md:flex">{renderNav()}</nav>
|
||||
<Drawer open={drawerOpen} onOpenChange={setDrawerOpen} shouldScaleBackground={false}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex h-10 w-full justify-between bg-transparent px-3 hover:bg-accent md:hidden"
|
||||
>
|
||||
<span className="truncate">{detailTitle}</span>
|
||||
<ChevronRight className="h-4 w-4 rotate-90 text-muted-foreground" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className="h-[88vh] overflow-hidden rounded-t-2xl border bg-popover text-popover-foreground">
|
||||
<DrawerHandle className="mt-3 h-1.5 w-10" />
|
||||
<DrawerTitle className="sr-only">Connectors navigation</DrawerTitle>
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-4">
|
||||
{renderNav()}
|
||||
</nav>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="hidden md:block">
|
||||
<h2 className="text-lg font-semibold">{detailTitle}</h2>
|
||||
<Separator className="mt-4 bg-border" />
|
||||
</div>
|
||||
<div className="min-w-0 pt-4 md:max-w-5xl">{isFlowActive ? flowView : catalog}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +1,38 @@
|
|||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
/**
|
||||
* Connectors retired during the MCP migration (no viable official MCP server).
|
||||
* The catalog card is shown disabled with a "Deprecated" badge so existing
|
||||
* users understand why; the backend `/add` routes also refuse with HTTP 410.
|
||||
* Reinstate by removing the type here and in the backend
|
||||
* `DEPRECATED_CONNECTOR_TYPES` if demand returns.
|
||||
*
|
||||
* UI behavior: a deprecated connector is hidden from the catalog entirely unless
|
||||
* the user already connected it before deprecation — in that case it still shows
|
||||
* as a normal card (inline, no "Deprecated" section) so they can manage/disconnect
|
||||
* it. New connections are refused by the backend `/add` routes with HTTP 410.
|
||||
* Reinstate by removing the type here and in the backend `DEPRECATED_CONNECTOR_TYPES`
|
||||
* if demand returns.
|
||||
*
|
||||
* Full deprecated list (for devs):
|
||||
* - DISCORD_CONNECTOR, TEAMS_CONNECTOR, LUMA_CONNECTOR
|
||||
* - Search APIs (superseded by the Google-only web-search consolidation; the
|
||||
* google_search subagent handles public web search, and Tavily/Linkup can
|
||||
* still be added via the generic Custom MCP connector with API-key headers):
|
||||
* TAVILY_API, SEARXNG_API, LINKUP_API, BAIDU_SEARCH_API
|
||||
* - Legacy content crawlers/search (superseded by the file Import menu and
|
||||
* hosted MCP tooling): YOUTUBE_CONNECTOR, WEBCRAWLER_CONNECTOR, ELASTICSEARCH_CONNECTOR
|
||||
*/
|
||||
export const DEPRECATED_CONNECTOR_TYPES = new Set<string>([
|
||||
EnumConnectorName.DISCORD_CONNECTOR,
|
||||
EnumConnectorName.TEAMS_CONNECTOR,
|
||||
EnumConnectorName.LUMA_CONNECTOR,
|
||||
// Search APIs retired by the Google-only web-search consolidation. Public
|
||||
// web search now runs through the google_search subagent; Tavily/Linkup can
|
||||
// still be added via the generic Custom MCP connector (API-key headers).
|
||||
EnumConnectorName.TAVILY_API,
|
||||
EnumConnectorName.SEARXNG_API,
|
||||
EnumConnectorName.LINKUP_API,
|
||||
EnumConnectorName.BAIDU_SEARCH_API,
|
||||
// Legacy content crawlers/search retired in favor of the file Import menu and
|
||||
// hosted MCP tooling. Existing rows stay manageable; new connections refused.
|
||||
EnumConnectorName.YOUTUBE_CONNECTOR,
|
||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
EnumConnectorName.ELASTICSEARCH_CONNECTOR,
|
||||
]);
|
||||
|
||||
/**
|
||||
* File-import connectors surfaced through the Documents sidebar "Import" menu
|
||||
* instead of the external-MCP connector catalog. They index remote files into
|
||||
* the knowledge base, so they belong with uploads, not with live MCP tools.
|
||||
*/
|
||||
export const IMPORT_CONNECTOR_TYPES = new Set<string>([
|
||||
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.ONEDRIVE_CONNECTOR,
|
||||
EnumConnectorName.DROPBOX_CONNECTOR,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Connectors that operate in real time (no background indexing).
|
||||
* Used to adjust UI: hide sync controls, show "Connected" instead of doc counts.
|
||||
|
|
@ -283,36 +279,35 @@ export function getConnectorTitle(connectorType: string): string {
|
|||
);
|
||||
}
|
||||
|
||||
/** One row per connected connector type (multiple accounts collapsed into one). */
|
||||
export interface ConnectorTypeRow {
|
||||
type: string;
|
||||
title: string;
|
||||
connectors: SearchSourceConnector[];
|
||||
accountCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary way a user interacts with a connector.
|
||||
* Drives the two top-level groupings in the connector catalog UI.
|
||||
* Collapse a flat connector list into one row per `connector_type`, titled for
|
||||
* display (MCP collapses to "MCP Servers") and sorted alphabetically. Shared by
|
||||
* the connectors panel's "Your connectors" rail and the composer add-menu so the
|
||||
* two lists never drift; callers layer their own extras (e.g. health) on top.
|
||||
*/
|
||||
export type ConnectorCategory = "knowledge_base" | "tools_live";
|
||||
|
||||
export const CONNECTOR_CATEGORY_LABELS: Record<ConnectorCategory, string> = {
|
||||
knowledge_base: "Knowledge Base",
|
||||
tools_live: "Tools & Live Sources",
|
||||
};
|
||||
|
||||
const KNOWLEDGE_BASE_CONNECTOR_TYPES = new Set<string>([
|
||||
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.ONEDRIVE_CONNECTOR,
|
||||
EnumConnectorName.DROPBOX_CONNECTOR,
|
||||
EnumConnectorName.NOTION_CONNECTOR,
|
||||
EnumConnectorName.CONFLUENCE_CONNECTOR,
|
||||
EnumConnectorName.YOUTUBE_CONNECTOR,
|
||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
EnumConnectorName.BOOKSTACK_CONNECTOR,
|
||||
EnumConnectorName.GITHUB_CONNECTOR,
|
||||
EnumConnectorName.ELASTICSEARCH_CONNECTOR,
|
||||
EnumConnectorName.CIRCLEBACK_CONNECTOR,
|
||||
EnumConnectorName.OBSIDIAN_CONNECTOR,
|
||||
]);
|
||||
|
||||
/** Unmapped connectors surface under Tools & Live Sources. */
|
||||
export function getConnectorCategory(connectorType: string): ConnectorCategory {
|
||||
return KNOWLEDGE_BASE_CONNECTOR_TYPES.has(connectorType) ? "knowledge_base" : "tools_live";
|
||||
export function groupConnectorsByType(connectors: SearchSourceConnector[]): ConnectorTypeRow[] {
|
||||
const byType = new Map<string, SearchSourceConnector[]>();
|
||||
for (const c of connectors) {
|
||||
const arr = byType.get(c.connector_type) ?? [];
|
||||
arr.push(c);
|
||||
byType.set(c.connector_type, arr);
|
||||
}
|
||||
return [...byType.entries()]
|
||||
.map(([type, list]) => ({
|
||||
type,
|
||||
title: type === EnumConnectorName.MCP_CONNECTOR ? "MCP Servers" : getConnectorTitle(type),
|
||||
connectors: list,
|
||||
accountCount: list.length,
|
||||
}))
|
||||
.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
// Composio Toolkits (available integrations via Composio)
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types
|
|||
import { authenticatedFetch } from "@/lib/auth-fetch";
|
||||
import { buildBackendUrl } from "@/lib/env-config";
|
||||
import {
|
||||
trackConnectorConnected,
|
||||
trackConnectorDeleted,
|
||||
trackConnectorSetupFailure,
|
||||
trackConnectorSetupStarted,
|
||||
trackIndexWithDateRangeOpened,
|
||||
|
|
@ -296,11 +294,9 @@ export const useConnectorDialog = () => {
|
|||
if (newConnector && oauthConnector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
||||
if (connectorValidation.success) {
|
||||
trackConnectorConnected(
|
||||
Number(workspaceId),
|
||||
oauthConnector.connectorType,
|
||||
newConnector.id
|
||||
);
|
||||
// connector_connected is now emitted server-side (after_insert
|
||||
// listener in search_source_connectors_routes.py) — covers the
|
||||
// OAuth callback redirect the frontend often never observes.
|
||||
|
||||
const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType);
|
||||
|
||||
|
|
@ -436,12 +432,7 @@ export const useConnectorDialog = () => {
|
|||
if (connector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(connector);
|
||||
if (connectorValidation.success) {
|
||||
// Track webcrawler connector connected
|
||||
trackConnectorConnected(
|
||||
Number(workspaceId),
|
||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
connector.id
|
||||
);
|
||||
// connector_connected is now emitted server-side (after_insert).
|
||||
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
|
|
@ -540,8 +531,7 @@ export const useConnectorDialog = () => {
|
|||
// Store connectingConnectorType before clearing it
|
||||
const currentConnectorType = connectingConnectorType;
|
||||
|
||||
// Track connector connected event for non-OAuth connectors
|
||||
trackConnectorConnected(Number(workspaceId), currentConnectorType, connector.id);
|
||||
// connector_connected is now emitted server-side (after_insert).
|
||||
|
||||
// Find connector title from constants
|
||||
const connectorInfo = OTHER_CONNECTORS.find(
|
||||
|
|
@ -1229,12 +1219,8 @@ export const useConnectorDialog = () => {
|
|||
id: editingConnector.id,
|
||||
});
|
||||
|
||||
// Track connector deleted event
|
||||
trackConnectorDeleted(
|
||||
Number(workspaceId),
|
||||
editingConnector.connector_type,
|
||||
editingConnector.id
|
||||
);
|
||||
// connector_deleted is now emitted server-side
|
||||
// (search_source_connectors_routes.delete_search_source_connector).
|
||||
|
||||
toast.success(
|
||||
editingConnector.connector_type === "MCP_CONNECTOR"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { connectorIndexingMetadata } from "@/contracts/types/inbox.types";
|
||||
import { type ConnectorTypeRow, groupConnectorsByType } from "../constants/connector-constants";
|
||||
import { useIndexingConnectors } from "./use-indexing-connectors";
|
||||
|
||||
/** Health of a connected connector type, derived from indexing + inbox state. */
|
||||
export type ConnectorHealth = "syncing" | "failed" | "ok";
|
||||
|
||||
/** A grouped connector row enriched with live indexing health. */
|
||||
export interface ConnectorRow extends ConnectorTypeRow {
|
||||
health: ConnectorHealth;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the "Your connectors" list: groups the connectors
|
||||
* by type and derives each row's health (syncing > failed > ok) from the status
|
||||
* inbox and optimistic indexing state. Shared by the connectors panel rail and
|
||||
* the composer add-menu so both render identical status glyphs.
|
||||
*
|
||||
* Also returns the underlying indexing controls so the panel can reuse them for
|
||||
* its flow views instead of instantiating `useIndexingConnectors` twice.
|
||||
*/
|
||||
export function useConnectorRows(connectors: SearchSourceConnector[]) {
|
||||
const statusInboxItems = useAtomValue(statusInboxItemsAtom);
|
||||
const inboxItems = useMemo(
|
||||
() => statusInboxItems.filter((item) => item.type === "connector_indexing"),
|
||||
[statusInboxItems]
|
||||
);
|
||||
|
||||
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
|
||||
connectors,
|
||||
inboxItems
|
||||
);
|
||||
|
||||
// Latest indexing status per connector id, parsed from the status inbox.
|
||||
const statusByConnectorId = useMemo(() => {
|
||||
const map = new Map<number, { status?: string; error?: string; at: string }>();
|
||||
for (const item of inboxItems) {
|
||||
const parsed = connectorIndexingMetadata.safeParse(item.metadata);
|
||||
if (!parsed.success) continue;
|
||||
const at = item.updated_at ?? item.created_at;
|
||||
const prev = map.get(parsed.data.connector_id);
|
||||
if (!prev || at > prev.at) {
|
||||
map.set(parsed.data.connector_id, {
|
||||
status: parsed.data.status,
|
||||
error: parsed.data.error_message ?? undefined,
|
||||
at,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [inboxItems]);
|
||||
|
||||
const rows = useMemo<ConnectorRow[]>(() => {
|
||||
return groupConnectorsByType(connectors).map((row) => {
|
||||
const ids = row.connectors.map((c) => c.id);
|
||||
const syncing = ids.some(
|
||||
(id) => indexingConnectorIds.has(id) || statusByConnectorId.get(id)?.status === "in_progress"
|
||||
);
|
||||
const failedEntry = syncing
|
||||
? undefined
|
||||
: ids.map((id) => statusByConnectorId.get(id)).find((s) => s?.status === "failed");
|
||||
return {
|
||||
...row,
|
||||
health: syncing ? "syncing" : failedEntry ? "failed" : "ok",
|
||||
errorMessage: failedEntry?.error,
|
||||
} satisfies ConnectorRow;
|
||||
});
|
||||
}, [connectors, indexingConnectorIds, statusByConnectorId]);
|
||||
|
||||
return { rows, indexingConnectorIds, startIndexing, stopIndexing, inboxItems };
|
||||
}
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
// Main component export
|
||||
export { ConnectorIndicator } from "../connector-popup";
|
||||
|
||||
// Sub-components (if needed for external use)
|
||||
export { ConnectorCard } from "./components/connector-card";
|
||||
export { ConnectorDialogHeader } from "./components/connector-dialog-header";
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { getDocumentTypeLabel } from "@/lib/documents/document-type-labels";
|
|||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
IMPORT_CONNECTOR_TYPES,
|
||||
LIVE_CONNECTOR_TYPES,
|
||||
OAUTH_CONNECTORS,
|
||||
} from "../constants/connector-constants";
|
||||
|
|
@ -40,11 +39,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
|||
onManage,
|
||||
onViewAccountsList,
|
||||
}) => {
|
||||
// Import connectors (Drive/OneDrive/Dropbox) are managed via the Documents
|
||||
// sidebar "Import" menu, so they are excluded from the MCP connector list.
|
||||
const connectors = allActiveConnectors.filter(
|
||||
(c) => !IMPORT_CONNECTOR_TYPES.has(c.connector_type)
|
||||
);
|
||||
const connectors = allActiveConnectors;
|
||||
// Convert activeDocumentTypes array to Record for utility function
|
||||
const documentTypeCounts = activeDocumentTypes.reduce(
|
||||
(acc, [docType, count]) => {
|
||||
|
|
@ -152,7 +147,6 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
|||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Search className="size-8 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No connectors found</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">Try a different search term</p>
|
||||
</div>
|
||||
) : hasSources ? (
|
||||
<div className="space-y-6">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useIsSelfHosted } from "@/components/providers/runtime-config";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
|
|
@ -9,12 +8,8 @@ import { usePlatform } from "@/hooks/use-platform";
|
|||
import { ConnectorCard } from "../components/connector-card";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
CONNECTOR_CATEGORY_LABELS,
|
||||
type ConnectorCategory,
|
||||
CRAWLERS,
|
||||
DEPRECATED_CONNECTOR_TYPES,
|
||||
getConnectorCategory,
|
||||
IMPORT_CONNECTOR_TYPES,
|
||||
OAUTH_CONNECTORS,
|
||||
OTHER_CONNECTORS,
|
||||
} from "../constants/connector-constants";
|
||||
|
|
@ -83,63 +78,39 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
const passesDeploymentFilter = (c: DeploymentFilterableConnector) =>
|
||||
(!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop);
|
||||
|
||||
// Import connectors (Drive/OneDrive/Dropbox) are managed via the Documents
|
||||
// sidebar "Import" menu, so they never appear in the MCP connector catalog.
|
||||
const notImport = (c: { connectorType?: string }) =>
|
||||
!c.connectorType || !IMPORT_CONNECTOR_TYPES.has(c.connectorType);
|
||||
|
||||
// Filter connectors based on search, deployment mode, and import exclusion
|
||||
// Filter connectors based on search and deployment mode
|
||||
const filteredOAuth = OAUTH_CONNECTORS.filter(
|
||||
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c)
|
||||
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
|
||||
);
|
||||
|
||||
const filteredCrawlers = CRAWLERS.filter(
|
||||
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c)
|
||||
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
|
||||
);
|
||||
|
||||
const filteredOther = OTHER_CONNECTORS.filter(
|
||||
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c) && notImport(c)
|
||||
(c) => matchesSearch(c.title, c.description) && passesDeploymentFilter(c)
|
||||
);
|
||||
|
||||
// Filter Composio connectors
|
||||
const filteredComposio = COMPOSIO_CONNECTORS.filter(
|
||||
(c) =>
|
||||
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
|
||||
notImport(c)
|
||||
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const isDeprecated = (c: { connectorType?: string }) =>
|
||||
!!c.connectorType && DEPRECATED_CONNECTOR_TYPES.has(c.connectorType);
|
||||
|
||||
const inCategory =
|
||||
(category: ConnectorCategory) =>
|
||||
<T extends { connectorType?: string }>(connector: T): boolean =>
|
||||
!isDeprecated(connector) &&
|
||||
!!connector.connectorType &&
|
||||
getConnectorCategory(connector.connectorType) === category;
|
||||
|
||||
const knowledgeBase = {
|
||||
oauth: filteredOAuth.filter(inCategory("knowledge_base")),
|
||||
composio: filteredComposio.filter(inCategory("knowledge_base")),
|
||||
other: filteredOther.filter(inCategory("knowledge_base")),
|
||||
crawlers: filteredCrawlers.filter(inCategory("knowledge_base")),
|
||||
};
|
||||
const toolsLive = {
|
||||
oauth: filteredOAuth.filter(inCategory("tools_live")),
|
||||
composio: filteredComposio.filter(inCategory("tools_live")),
|
||||
other: filteredOther.filter(inCategory("tools_live")),
|
||||
crawlers: filteredCrawlers.filter(inCategory("tools_live")),
|
||||
};
|
||||
|
||||
// Deprecated cards render in a single section at the very bottom. Discord and
|
||||
// Teams come from OAUTH_CONNECTORS; Luma, Tavily, Linkup, Baidu, Elasticsearch
|
||||
// from OTHER_CONNECTORS (SearXNG has no catalog card); YouTube and Web Pages
|
||||
// from CRAWLERS.
|
||||
const deprecated = {
|
||||
oauth: filteredOAuth.filter(isDeprecated),
|
||||
other: filteredOther.filter(isDeprecated),
|
||||
crawlers: filteredCrawlers.filter(isDeprecated),
|
||||
// One flat grid, no separate "Deprecated" section. A deprecated connector is
|
||||
// shown only if the user already connected it (before deprecation), so it
|
||||
// stays manageable/disconnectable; never-connected deprecated connectors are
|
||||
// hidden entirely. See DEPRECATED_CONNECTOR_TYPES for the full list.
|
||||
const isVisible = <T extends { connectorType?: string }>(c: T) =>
|
||||
!c.connectorType ||
|
||||
!DEPRECATED_CONNECTOR_TYPES.has(c.connectorType) ||
|
||||
connectedTypes.has(c.connectorType);
|
||||
const available = {
|
||||
oauth: filteredOAuth.filter(isVisible),
|
||||
composio: filteredComposio.filter(isVisible),
|
||||
other: filteredOther.filter(isVisible),
|
||||
crawlers: filteredCrawlers.filter(isVisible),
|
||||
};
|
||||
|
||||
const renderOAuthCard = (connector: OAuthConnector | ComposioConnector) => {
|
||||
|
|
@ -169,7 +140,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
documentCount={documentCount}
|
||||
accountCount={accountCount}
|
||||
isIndexing={isIndexing}
|
||||
deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
|
||||
onConnect={() => onConnectOAuth(connector)}
|
||||
onManage={
|
||||
isConnected && onViewAccountsList
|
||||
|
|
@ -218,7 +188,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
documentCount={documentCount}
|
||||
connectorCount={mcpConnectorCount}
|
||||
isIndexing={isIndexing}
|
||||
deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
|
||||
onConnect={handleConnect}
|
||||
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
|
||||
/>
|
||||
|
|
@ -267,86 +236,34 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
isConnecting={isConnecting}
|
||||
documentCount={documentCount}
|
||||
isIndexing={isIndexing}
|
||||
deprecated={
|
||||
!!crawler.connectorType && DEPRECATED_CONNECTOR_TYPES.has(crawler.connectorType)
|
||||
}
|
||||
onConnect={handleConnect}
|
||||
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const hasKnowledgeBase =
|
||||
knowledgeBase.oauth.length > 0 ||
|
||||
knowledgeBase.composio.length > 0 ||
|
||||
knowledgeBase.other.length > 0 ||
|
||||
knowledgeBase.crawlers.length > 0;
|
||||
const hasToolsLive =
|
||||
toolsLive.oauth.length > 0 ||
|
||||
toolsLive.composio.length > 0 ||
|
||||
toolsLive.other.length > 0 ||
|
||||
toolsLive.crawlers.length > 0;
|
||||
const hasDeprecated =
|
||||
deprecated.oauth.length > 0 || deprecated.other.length > 0 || deprecated.crawlers.length > 0;
|
||||
|
||||
const hasAnyResults = hasKnowledgeBase || hasToolsLive || hasDeprecated;
|
||||
const hasAnyResults =
|
||||
available.oauth.length > 0 ||
|
||||
available.composio.length > 0 ||
|
||||
available.other.length > 0 ||
|
||||
available.crawlers.length > 0;
|
||||
|
||||
if (!hasAnyResults && searchQuery) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<Search className="size-8 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No connectors found</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">Try a different search term</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{hasKnowledgeBase && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{CONNECTOR_CATEGORY_LABELS.knowledge_base}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{knowledgeBase.oauth.map(renderOAuthCard)}
|
||||
{knowledgeBase.composio.map(renderOAuthCard)}
|
||||
{knowledgeBase.crawlers.map(renderCrawlerCard)}
|
||||
{knowledgeBase.other.map(renderOtherCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasToolsLive && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{CONNECTOR_CATEGORY_LABELS.tools_live}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{toolsLive.oauth.map(renderOAuthCard)}
|
||||
{toolsLive.composio.map(renderOAuthCard)}
|
||||
{toolsLive.crawlers.map(renderCrawlerCard)}
|
||||
{toolsLive.other.map(renderOtherCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{hasDeprecated && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Deprecated</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{deprecated.oauth.map(renderOAuthCard)}
|
||||
{deprecated.crawlers.map(renderCrawlerCard)}
|
||||
{deprecated.other.map(renderOtherCard)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{available.oauth.map(renderOAuthCard)}
|
||||
{available.composio.map(renderOAuthCard)}
|
||||
{available.crawlers.map(renderCrawlerCard)}
|
||||
{available.other.map(renderOtherCard)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-1 sm:pb-4 bg-popover">
|
||||
<div className="pb-1 sm:pb-4 bg-transparent">
|
||||
{/* Back button */}
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -165,7 +165,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 sm:px-12 pt-0 sm:pt-6 pb-6 sm:pb-8">
|
||||
<div className="flex-1 overflow-y-auto pt-0 sm:pt-6 pb-6 sm:pb-8">
|
||||
{/* Connected Accounts Grid */}
|
||||
{typeConnectors.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
|
|||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
|
||||
<div className="shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
|
|
@ -239,7 +239,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
|
|||
</div>
|
||||
|
||||
{/* Form Content - Scrollable */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 sm:px-12">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="space-y-4 pb-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="video-input" className="text-sm sm:text-base">
|
||||
|
|
@ -325,7 +325,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
|
|||
</div>
|
||||
|
||||
{/* Fixed Footer - Action buttons */}
|
||||
<div className="shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover">
|
||||
<div className="shrink-0 flex items-center justify-between py-6 bg-transparent">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
|
|
|
|||
|
|
@ -12,17 +12,17 @@ import {
|
|||
ArrowUpIcon,
|
||||
Camera,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clipboard,
|
||||
LayoutGrid,
|
||||
Plus,
|
||||
Settings2,
|
||||
SquareIcon,
|
||||
TriangleAlert,
|
||||
Unplug,
|
||||
Upload,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -44,7 +44,7 @@ import {
|
|||
clearPremiumAlertForThreadAtom,
|
||||
premiumAlertByThreadAtom,
|
||||
} from "@/atoms/chat/premium-alert.atom";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { importConnectorRequestAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
|
||||
|
|
@ -52,6 +52,11 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||
import { ChatViewport } from "@/components/assistant-ui/chat-viewport";
|
||||
import { ComposerAddMenuDrawer } from "@/components/assistant-ui/composer-add-menu-drawer";
|
||||
import {
|
||||
type ConnectorRow,
|
||||
useConnectorRows,
|
||||
} from "@/components/assistant-ui/connector-popup/hooks/use-connector-rows";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import {
|
||||
InlineMentionEditor,
|
||||
|
|
@ -68,19 +73,12 @@ import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer
|
|||
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
|
||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHandle,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
|
|
@ -88,6 +86,7 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverAnchor } from "@/components/ui/popover";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
|
@ -97,6 +96,7 @@ import {
|
|||
getToolDisplayName,
|
||||
getToolIcon,
|
||||
} from "@/contracts/enums/toolIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
|
|
@ -173,7 +173,7 @@ const ThreadContent: FC<ThreadProps> = ({
|
|||
footer={
|
||||
<>
|
||||
<PremiumQuotaPinnedAlert />
|
||||
<Composer hasActiveThread={hasActiveThread} isLoadingMessages={isLoadingMessages} />
|
||||
<Composer isLoadingMessages={isLoadingMessages} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
|
@ -313,99 +313,6 @@ const ThreadWelcome: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const BANNER_CONNECTORS = [
|
||||
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
|
||||
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
|
||||
{ type: "NOTION_CONNECTOR", label: "Notion" },
|
||||
{ type: "YOUTUBE_CONNECTOR", label: "YouTube" },
|
||||
{ type: "SLACK_CONNECTOR", label: "Slack" },
|
||||
] as const;
|
||||
|
||||
const BANNER_DISMISSED_KEY = "surfsense-connect-tools-banner-dismissed";
|
||||
|
||||
const ConnectToolsBanner: FC<{
|
||||
isThreadEmpty: boolean;
|
||||
onVisibleChange?: (visible: boolean) => void;
|
||||
}> = ({ isThreadEmpty, onVisibleChange }) => {
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return localStorage.getItem(BANNER_DISMISSED_KEY) === "true";
|
||||
});
|
||||
const [dismissRequested, setDismissRequested] = useState(false);
|
||||
|
||||
const hasConnectors = (connectors?.length ?? 0) > 0;
|
||||
const isVisible = !dismissed && !hasConnectors && isThreadEmpty;
|
||||
const shouldShowTray = isVisible && !dismissRequested;
|
||||
|
||||
useEffect(() => {
|
||||
onVisibleChange?.(isVisible);
|
||||
}, [isVisible, onVisibleChange]);
|
||||
|
||||
const handleDismiss = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setDismissRequested(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence
|
||||
initial={false}
|
||||
onExitComplete={() => {
|
||||
if (!dismissRequested) return;
|
||||
setDismissed(true);
|
||||
localStorage.setItem(BANNER_DISMISSED_KEY, "true");
|
||||
}}
|
||||
>
|
||||
{shouldShowTray ? (
|
||||
<motion.div
|
||||
key="connect-tools-tray"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -14 }}
|
||||
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||
className="relative z-0 -mt-5 flex min-w-0 items-center gap-2 rounded-b-3xl border border-input bg-muted/40 px-4 pt-7 pb-3 shadow-sm shadow-black/5 dark:shadow-black/10"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 min-w-0 cursor-pointer justify-start gap-2 rounded-md px-0 text-[13px] font-normal text-muted-foreground select-none hover:bg-transparent hover:text-foreground"
|
||||
onClick={() => setConnectorDialogOpen(true)}
|
||||
>
|
||||
<Unplug className="size-4 shrink-0" />
|
||||
<span className="truncate">Connect your tools</span>
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1" />
|
||||
<AvatarGroup className="shrink-0">
|
||||
{BANNER_CONNECTORS.map(({ type }, i) => (
|
||||
<Avatar
|
||||
key={type}
|
||||
className="size-5"
|
||||
style={{ zIndex: BANNER_CONNECTORS.length - i }}
|
||||
>
|
||||
<AvatarFallback className="bg-accent text-[10px]">
|
||||
{getConnectorIcon(type, "size-3")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</AvatarGroup>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 cursor-pointer rounded-md text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
const PendingScreenImageStrip: FC = () => {
|
||||
const [urls, setUrls] = useAtom(pendingUserImageDataUrlsAtom);
|
||||
if (urls.length === 0) return null;
|
||||
|
|
@ -485,8 +392,7 @@ const ClipboardChip: FC<{ text: string; onDismiss: () => void }> = ({ text, onDi
|
|||
};
|
||||
|
||||
/**
|
||||
* Inline "this workspace can't chat yet" tray, mirrored on top of the composer
|
||||
* (same visual treatment as the "Connect your tools" tray below it).
|
||||
* Inline "this workspace can't chat yet" tray, mirrored on top of the composer.
|
||||
*
|
||||
* Replaces the old full-screen onboarding redirect: a workspace with no usable
|
||||
* chat model (fresh install, or an admin deleted the last model) renders the
|
||||
|
|
@ -531,14 +437,10 @@ const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }>
|
|||
};
|
||||
|
||||
interface ComposerProps {
|
||||
hasActiveThread?: boolean;
|
||||
isLoadingMessages?: boolean;
|
||||
}
|
||||
|
||||
const Composer: FC<ComposerProps> = ({
|
||||
hasActiveThread = false,
|
||||
isLoadingMessages = false,
|
||||
}) => {
|
||||
const Composer: FC<ComposerProps> = ({ isLoadingMessages = false }) => {
|
||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||
const setSubmittedMentions = useSetAtom(submittedMentionsAtom);
|
||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
||||
|
|
@ -575,7 +477,6 @@ const Composer: FC<ComposerProps> = ({
|
|||
|
||||
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
|
||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
const [connectToolsTrayVisible, setConnectToolsTrayVisible] = useState(false);
|
||||
|
||||
const { data: chatSetupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId ?? 0));
|
||||
const isChatUnavailable = !!chatSetupStatus && chatSetupStatus.status !== "ready";
|
||||
|
|
@ -1037,7 +938,6 @@ const Composer: FC<ComposerProps> = ({
|
|||
<div
|
||||
className={cn(
|
||||
"aui-composer-attachment-dropzone relative z-10 flex w-full flex-col overflow-hidden rounded-3xl border border-input/20 bg-muted pt-2 shadow-sm shadow-black/5 outline-none transition-[border-color,box-shadow] hover:border-input/60 focus-within:border-input/60 dark:shadow-black/10",
|
||||
connectToolsTrayVisible && "rounded-b-3xl shadow-none dark:shadow-none",
|
||||
isChatUnavailable && "shadow-none dark:shadow-none"
|
||||
)}
|
||||
>
|
||||
|
|
@ -1071,10 +971,6 @@ const Composer: FC<ComposerProps> = ({
|
|||
onChatModelSelected={handleChatModelSelected}
|
||||
/>
|
||||
</div>
|
||||
<ConnectToolsBanner
|
||||
isThreadEmpty={!hasActiveThread && isThreadEmpty}
|
||||
onVisibleChange={setConnectToolsTrayVisible}
|
||||
/>
|
||||
{!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
|
||||
<div className="absolute top-full left-0 right-0 z-20">
|
||||
<ChatExamplePrompts onSelect={handleExampleSelect} />
|
||||
|
|
@ -1152,12 +1048,13 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
onChatModelSelected,
|
||||
}) => {
|
||||
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
const router = useRouter();
|
||||
const openConnectors = useCallback(
|
||||
() => router.push(`/dashboard/${workspaceId}/connectors`),
|
||||
[router, workspaceId]
|
||||
);
|
||||
const [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
|
||||
const [openConnectorSubmenu, setOpenConnectorSubmenu] = useState<string | null>(null);
|
||||
const [expandedConnectorGroups, setExpandedConnectorGroups] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
const isDesktop = useMediaQuery("(min-width: 640px)");
|
||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||
const pendingScreenImages = useAtomValue(pendingUserImageDataUrlsAtom);
|
||||
|
|
@ -1192,6 +1089,20 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
() => new Set<string>((connectors ?? []).map((c) => c.connector_type)),
|
||||
[connectors]
|
||||
);
|
||||
// "Your connectors" rows for the add-menu (desktop submenu + mobile drawer):
|
||||
// same hook the connectors panel rail uses, so grouping + health glyphs match.
|
||||
const { rows: connectorRows } = useConnectorRows((connectors ?? []) as SearchSourceConnector[]);
|
||||
const setImportRequest = useSetAtom(importConnectorRequestAtom);
|
||||
const openConnectorManage = useCallback(
|
||||
(row: ConnectorRow) => {
|
||||
// Deep-link into the connector's manage view: the panel's
|
||||
// useConnectorDialog consumes this atom and routes by account count
|
||||
// (none -> connect, one -> edit, many -> accounts list).
|
||||
setImportRequest({ connectorType: row.type, mode: "auto" });
|
||||
openConnectors();
|
||||
},
|
||||
[setImportRequest, openConnectors]
|
||||
);
|
||||
|
||||
const toggleToolGroup = useCallback(
|
||||
(toolNames: string[]) => {
|
||||
|
|
@ -1204,18 +1115,6 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
},
|
||||
[disabledToolsSet, setDisabledTools]
|
||||
);
|
||||
const setConnectorGroupExpanded = useCallback((label: string, expanded: boolean) => {
|
||||
setExpandedConnectorGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (expanded) {
|
||||
next.add(label);
|
||||
} else {
|
||||
next.delete(label);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filteredTools = agentTools;
|
||||
const groupedTools = useMemo(() => {
|
||||
if (!filteredTools) return [];
|
||||
|
|
@ -1284,206 +1183,30 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
<div className="aui-composer-action-wrapper relative mx-3 mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
{!isDesktop ? (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full p-0 font-semibold text-xs text-muted-foreground transition-colors dark:border-muted-foreground/15 hover:bg-foreground/10 hover:text-foreground"
|
||||
aria-label="Upload files, manage tools and more"
|
||||
data-joyride="connector-icon"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="start" sideOffset={8}>
|
||||
<DropdownMenuItem onSelect={() => openUploadDialog()}>
|
||||
<Upload className="size-4" />
|
||||
Upload Files
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
|
||||
<Unplug className="size-4" />
|
||||
MCP Connectors
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setToolsPopoverOpen(true)}>
|
||||
<Settings2 className="size-4" />
|
||||
Manage Tools
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Drawer
|
||||
open={toolsPopoverOpen}
|
||||
onOpenChange={setToolsPopoverOpen}
|
||||
shouldScaleBackground={false}
|
||||
>
|
||||
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
|
||||
<DrawerHandle />
|
||||
<DrawerHeader className="px-4 pb-3 pt-2">
|
||||
<DrawerTitle className="flex items-center justify-center gap-2 text-base font-semibold">
|
||||
Manage Tools
|
||||
</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin pb-6">
|
||||
{regularToolGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.tools.map((tool) => {
|
||||
const isDisabled = disabledToolsSet.has(tool.name);
|
||||
const ToolIcon = getToolIcon(tool.name);
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 text-sm font-medium truncate">
|
||||
{formatToolName(tool.name)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={!isDisabled}
|
||||
onCheckedChange={() => toggleTool(tool.name)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{connectorToolGroups.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
|
||||
Connector Actions
|
||||
</div>
|
||||
{connectorToolGroups.map((group) => {
|
||||
const iconKey = group.connectorIcon ?? "";
|
||||
const iconInfo = CONNECTOR_TOOL_ICON_PATHS[iconKey];
|
||||
const toolNames = group.tools.map((t) => t.name);
|
||||
const allDisabled = toolNames.every((n) => disabledToolsSet.has(n));
|
||||
const isExpanded = expandedConnectorGroups.has(group.label);
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.label}
|
||||
open={isExpanded}
|
||||
onOpenChange={(open) => setConnectorGroupExpanded(group.label, open)}
|
||||
>
|
||||
<div className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto min-w-0 flex-1 justify-start gap-3 p-0 text-left hover:bg-transparent hover:text-inherit"
|
||||
>
|
||||
{iconInfo ? (
|
||||
<Image
|
||||
src={iconInfo.src}
|
||||
alt={iconInfo.alt}
|
||||
width={18}
|
||||
height={18}
|
||||
className="size-[18px] shrink-0 select-none pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<Wrench className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{group.label}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<Switch
|
||||
checked={!allDisabled}
|
||||
onCheckedChange={() => toggleToolGroup(toolNames)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<CollapsibleContent className="pb-1">
|
||||
{group.tools.map((tool) => {
|
||||
const isDisabled = disabledToolsSet.has(tool.name);
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className={cn(
|
||||
"ml-8 flex items-center gap-3 px-4 py-1.5 rounded-md transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
!isDisabled && "text-primary"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{formatToolName(tool.name)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={!isDisabled}
|
||||
onCheckedChange={() => toggleTool(tool.name)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{otherToolGroup && (
|
||||
<div>
|
||||
<div className="px-4 pt-3 pb-1 text-xs text-muted-foreground/80 font-medium select-none">
|
||||
{otherToolGroup.label}
|
||||
</div>
|
||||
{otherToolGroup.tools.map((tool) => {
|
||||
const isDisabled = disabledToolsSet.has(tool.name);
|
||||
const ToolIcon = getToolIcon(tool.name);
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="flex w-full items-center gap-3 px-4 py-2 hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<ToolIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 min-w-0 text-sm font-medium truncate">
|
||||
{formatToolName(tool.name)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={!isDisabled}
|
||||
onCheckedChange={() => toggleTool(tool.name)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!filteredTools?.length && (
|
||||
<div className="px-4 pt-3 pb-2">
|
||||
<Skeleton className="h-3 w-16 mb-2" />
|
||||
{["t1", "t2", "t3", "t4"].map((k) => (
|
||||
<div key={k} className="flex items-center gap-3 py-2">
|
||||
<Skeleton className="size-4 rounded shrink-0" />
|
||||
<Skeleton className="h-3.5 flex-1" />
|
||||
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
<Skeleton className="h-3 w-24 mt-3 mb-2" />
|
||||
{["c1", "c2", "c3"].map((k) => (
|
||||
<div key={k} className="flex items-center gap-3 py-2">
|
||||
<Skeleton className="size-4 rounded shrink-0" />
|
||||
<Skeleton className="h-3.5 flex-1" />
|
||||
<Skeleton className="h-5 w-9 rounded-full shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
<ComposerAddMenuDrawer
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full p-0 font-semibold text-xs text-muted-foreground transition-colors dark:border-muted-foreground/15 hover:bg-foreground/10 hover:text-foreground"
|
||||
aria-label="Upload files, manage tools and more"
|
||||
data-joyride="connector-icon"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</Button>
|
||||
}
|
||||
onUploadFiles={() => openUploadDialog()}
|
||||
connectorRows={connectorRows}
|
||||
onSelectConnector={openConnectorManage}
|
||||
onBrowseConnectors={openConnectors}
|
||||
regularToolGroups={regularToolGroups}
|
||||
connectorToolGroups={connectorToolGroups}
|
||||
otherToolGroup={otherToolGroup}
|
||||
disabledToolsSet={disabledToolsSet}
|
||||
onToggleTool={toggleTool}
|
||||
onToggleToolGroup={toggleToolGroup}
|
||||
toolsLoading={!filteredTools?.length}
|
||||
/>
|
||||
) : (
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
|
|
@ -1522,10 +1245,53 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
<Camera className="h-4 w-4" />
|
||||
Take a screenshot
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
|
||||
<Unplug className="h-4 w-4" />
|
||||
MCP Connectors
|
||||
</DropdownMenuItem>
|
||||
{connectorRows.length > 0 ? (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Unplug className="h-4 w-4" />
|
||||
MCP Connectors
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent
|
||||
collisionPadding={8}
|
||||
className="w-60 max-h-72 gap-1 overflow-y-auto overscroll-none"
|
||||
>
|
||||
{connectorRows.map((row) => (
|
||||
<DropdownMenuItem
|
||||
key={row.type}
|
||||
onSelect={() => openConnectorManage(row)}
|
||||
className="gap-2"
|
||||
>
|
||||
{getConnectorIcon(row.type, "h-4 w-4")}
|
||||
<span className="min-w-0 flex-1 truncate">{row.title}</span>
|
||||
{row.health === "syncing" ? (
|
||||
<Spinner size="xs" className="shrink-0" />
|
||||
) : row.health === "failed" ? (
|
||||
<TriangleAlert
|
||||
className="h-3.5 w-3.5 shrink-0 text-destructive"
|
||||
aria-label={row.errorMessage ?? "Indexing failed"}
|
||||
/>
|
||||
) : row.accountCount > 1 ? (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{row.accountCount}
|
||||
</span>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={openConnectors} className="gap-2">
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
Manage connectors
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={openConnectors}>
|
||||
<Unplug className="h-4 w-4" />
|
||||
MCP Connectors
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSub
|
||||
open={toolsPopoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
|
@ -1546,7 +1312,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
>
|
||||
{regularToolGroups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none">
|
||||
<div className="px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground font-semibold select-none">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.tools.map((tool) => {
|
||||
|
|
@ -1581,7 +1347,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
))}
|
||||
{connectorToolGroups.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none">
|
||||
<div className="px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground font-semibold select-none">
|
||||
Connector Actions
|
||||
</div>
|
||||
{connectorToolGroups.map((group) => {
|
||||
|
|
@ -1667,7 +1433,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
|||
)}
|
||||
{otherToolGroup && (
|
||||
<div>
|
||||
<div className="px-2 pt-1.5 pb-0.5 text-[10px] text-muted-foreground/80 font-normal select-none">
|
||||
<div className="px-2 pt-1.5 pb-0.5 text-xs text-muted-foreground font-semibold select-none">
|
||||
{otherToolGroup.label}
|
||||
</div>
|
||||
{otherToolGroup.tools.map((tool) => {
|
||||
|
|
|
|||
|
|
@ -324,7 +324,6 @@ export function FolderTreeView({
|
|||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-12 text-muted-foreground">
|
||||
<Search className="h-10 w-10" />
|
||||
<p className="text-sm text-muted-foreground">No matching documents</p>
|
||||
<p className="text-xs text-muted-foreground/70 mt-1">Try a different search term</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlarmClock, AlertTriangle, Shapes, SquareTerminal } from "lucide-react";
|
||||
import { AlarmClock, AlertTriangle, Shapes, SquareTerminal, Unplug } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
|
|
@ -300,6 +300,7 @@ export function LayoutDataProvider({
|
|||
const isAutomationsActive = pathname?.includes("/automations") === true;
|
||||
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
|
||||
const isPlaygroundRoute = pathname?.includes("/playground") === true;
|
||||
const isConnectorsRoute = pathname?.includes("/connectors") === true;
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() =>
|
||||
(
|
||||
|
|
@ -316,6 +317,12 @@ export function LayoutDataProvider({
|
|||
icon: Shapes,
|
||||
isActive: isArtifactsActive,
|
||||
},
|
||||
{
|
||||
title: "Connectors",
|
||||
url: `/dashboard/${workspaceId}/connectors`,
|
||||
icon: Unplug,
|
||||
isActive: isConnectorsRoute,
|
||||
},
|
||||
{
|
||||
title: "Playground",
|
||||
url: `/dashboard/${workspaceId}/playground`,
|
||||
|
|
@ -324,7 +331,7 @@ export function LayoutDataProvider({
|
|||
},
|
||||
] as (NavItem | null)[]
|
||||
).filter((item): item is NavItem => item !== null),
|
||||
[workspaceId, isAutomationsActive, isArtifactsActive, isPlaygroundRoute]
|
||||
[workspaceId, isAutomationsActive, isArtifactsActive, isConnectorsRoute, isPlaygroundRoute]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
|
|
@ -631,6 +638,7 @@ export function LayoutDataProvider({
|
|||
const isAutomationsPage = pathname?.includes("/automations") === true;
|
||||
const isArtifactsPage = pathname?.endsWith("/artifacts") === true;
|
||||
const isPlaygroundPage = pathname?.includes("/playground") === true;
|
||||
const isConnectorsPage = pathname?.includes("/connectors") === true;
|
||||
const isAllChatsPage = pathname?.endsWith("/chats") === true;
|
||||
const handleChatsClick = useCallback(() => {
|
||||
router.push(`/dashboard/${workspaceId}/chats`);
|
||||
|
|
@ -650,6 +658,7 @@ export function LayoutDataProvider({
|
|||
isAutomationsPage ||
|
||||
isArtifactsPage ||
|
||||
isPlaygroundPage ||
|
||||
isConnectorsPage ||
|
||||
isAllChatsPage;
|
||||
|
||||
return (
|
||||
|
|
@ -702,6 +711,7 @@ export function LayoutDataProvider({
|
|||
isAutomationsPage ||
|
||||
isArtifactsPage ||
|
||||
isPlaygroundPage ||
|
||||
isConnectorsPage ||
|
||||
isAllChatsPage
|
||||
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
|
||||
: undefined
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import {
|
|||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { trackWorkspaceCreated } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
|
|
@ -68,7 +67,8 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
|
|||
description: values.description || "",
|
||||
});
|
||||
|
||||
trackWorkspaceCreated(result.id, values.name);
|
||||
// workspace_created is now emitted server-side (workspaces_routes.py)
|
||||
// so PAT/MCP-created workspaces are also counted.
|
||||
|
||||
// Seed the gate's query so it resolves without a loader flash, and
|
||||
// route straight to onboarding vs. new-chat on the first hop.
|
||||
|
|
|
|||
|
|
@ -487,13 +487,9 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
|
|||
</div>
|
||||
) : isSearchMode ? (
|
||||
<div className="text-center py-8">
|
||||
<Search className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("no_chats_found") || "No chats found"}
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
||||
{t("try_different_search") || "Try a different search term"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
|
|
|
|||
|
|
@ -8,25 +8,18 @@ import {
|
|||
FolderPlus,
|
||||
FolderSync,
|
||||
ListFilter,
|
||||
Plus,
|
||||
Settings2,
|
||||
SlidersVertical,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { importConnectorRequestAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
|
||||
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
||||
import { useConnectorStatus } from "@/components/assistant-ui/connector-popup/hooks/use-connector-status";
|
||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
|
||||
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
||||
import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon";
|
||||
|
|
@ -34,7 +27,7 @@ import type { FolderDisplay } from "@/components/documents/FolderNode";
|
|||
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
|
||||
import { FolderTreeView } from "@/components/documents/FolderTreeView";
|
||||
import { VersionHistoryDialog } from "@/components/documents/version-history";
|
||||
import { useOptionalRuntimeConfig, useRuntimeConfig } from "@/components/providers/runtime-config";
|
||||
import { useRuntimeConfig } from "@/components/providers/runtime-config";
|
||||
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
|
||||
import {
|
||||
DEFAULT_EXCLUDE_PATTERNS,
|
||||
|
|
@ -57,7 +50,6 @@ import {
|
|||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
|
|
@ -67,9 +59,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
|
||||
import { useLoginGate } from "@/contexts/login-gate";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
|
||||
|
|
@ -239,11 +228,9 @@ export function EmbeddedDocumentsMenu({
|
|||
}
|
||||
|
||||
/**
|
||||
* Import menu: local file upload plus the cloud-drive import connectors
|
||||
* (Google Drive / OneDrive / Dropbox). Drive/OneDrive/Dropbox set
|
||||
* `importConnectorRequestAtom`, which the connector dialog consumes to run
|
||||
* OAuth or open the existing account's config. In anonymous mode, `gate`
|
||||
* intercepts every item to trigger the login flow.
|
||||
* Desktop-only "Watch Local Folder" entry point. File uploads now live in the
|
||||
* chat composer and cloud drives in the connector catalog, so this renders
|
||||
* nothing on web. `gate` login-gates the action in anonymous mode.
|
||||
*/
|
||||
export function EmbeddedImportMenu({
|
||||
gate,
|
||||
|
|
@ -252,30 +239,14 @@ export function EmbeddedImportMenu({
|
|||
gate?: (feature: string) => void;
|
||||
onFolderWatched?: () => void;
|
||||
}) {
|
||||
const { openDialog } = useDocumentUploadDialog();
|
||||
const setImportRequest = useSetAtom(importConnectorRequestAtom);
|
||||
// Provider is absent on anonymous /free pages, where every item is login-gated
|
||||
// anyway — defaulting to hosted (Composio) there is cosmetic.
|
||||
const selfHosted = useOptionalRuntimeConfig()?.deploymentMode === "self-hosted";
|
||||
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
|
||||
const { data: connectors } = useAtomValue(connectorsAtom);
|
||||
|
||||
// Watch Local Folder is a desktop-app feature (needs the Electron folder watcher).
|
||||
const { isDesktop } = usePlatform();
|
||||
const params = useParams();
|
||||
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
|
||||
const [folderWatchOpen, setFolderWatchOpen] = useState(false);
|
||||
|
||||
// Native Google Drive connector self-hosted only; hosted deployments use Composio.
|
||||
const driveType = selfHosted
|
||||
? EnumConnectorName.GOOGLE_DRIVE_CONNECTOR
|
||||
: EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR;
|
||||
|
||||
const cloudItems = [
|
||||
{ type: driveType, label: "Google Drive" },
|
||||
{ type: EnumConnectorName.ONEDRIVE_CONNECTOR, label: "OneDrive" },
|
||||
{ type: EnumConnectorName.DROPBOX_CONNECTOR, label: "Dropbox" },
|
||||
];
|
||||
// Nothing to import on web anymore — hide the button rather than show an empty menu.
|
||||
if (!isDesktop) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
|
@ -285,99 +256,20 @@ export function EmbeddedImportMenu({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Import documents"
|
||||
aria-label="Watch local folder"
|
||||
>
|
||||
<FilePlus className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuItem onSelect={() => (gate ? gate("upload files") : openDialog())}>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Files
|
||||
<DropdownMenuItem
|
||||
onSelect={() => (gate ? gate("watch local folders") : setFolderWatchOpen(true))}
|
||||
>
|
||||
<FolderSync className="h-4 w-4" />
|
||||
Watch Local Folder
|
||||
</DropdownMenuItem>
|
||||
{isDesktop && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => (gate ? gate("watch local folders") : setFolderWatchOpen(true))}
|
||||
>
|
||||
<FolderSync className="h-4 w-4" />
|
||||
Watch Local Folder
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{cloudItems.map((item) => {
|
||||
const enabled = gate ? true : isConnectorEnabled(item.type);
|
||||
const statusMessage = enabled ? null : getConnectorStatusMessage(item.type);
|
||||
const icon = getConnectorIcon(item.type, "h-4 w-4");
|
||||
// gate = anonymous mode; treat every connector as unconnected so items
|
||||
// route through the login gate rather than reading workspace connectors.
|
||||
const accountCount = gate
|
||||
? 0
|
||||
: (connectors ?? []).filter(
|
||||
(c: SearchSourceConnector) => c.connector_type === item.type
|
||||
).length;
|
||||
|
||||
// Unavailable (e.g. maintenance): non-actionable item explaining why.
|
||||
if (!enabled) {
|
||||
return (
|
||||
<DropdownMenuItem key={item.type} disabled title={statusMessage ?? undefined}>
|
||||
{icon}
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Connected: manage the existing account(s) or add another.
|
||||
if (accountCount > 0) {
|
||||
return (
|
||||
<DropdownMenuSub key={item.type}>
|
||||
<DropdownMenuSubTrigger>
|
||||
{icon}
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{accountCount}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-48">
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
gate
|
||||
? gate("manage import connectors")
|
||||
: setImportRequest({ connectorType: item.type, mode: "auto" })
|
||||
}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{accountCount > 1 ? "Manage accounts" : "Manage"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
gate
|
||||
? gate("import from cloud storage")
|
||||
: setImportRequest({ connectorType: item.type, mode: "connect" })
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add another account
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
// Not connected: single click starts the first OAuth connect.
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.type}
|
||||
onSelect={() =>
|
||||
gate
|
||||
? gate("import from cloud storage")
|
||||
: setImportRequest({ connectorType: item.type, mode: "auto" })
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
{isDesktop && !gate && (
|
||||
{!gate && (
|
||||
<FolderWatchDialog
|
||||
open={folderWatchOpen}
|
||||
onOpenChange={setFolderWatchOpen}
|
||||
|
|
|
|||
|
|
@ -140,6 +140,10 @@ export function Sidebar({
|
|||
() => navItems.find((item) => item.url.endsWith("/artifacts")),
|
||||
[navItems]
|
||||
);
|
||||
const connectorsItem = useMemo(
|
||||
() => navItems.find((item) => item.url.endsWith("/connectors")),
|
||||
[navItems]
|
||||
);
|
||||
const playgroundItem = useMemo(
|
||||
() => navItems.find((item) => item.url.endsWith("/playground")),
|
||||
[navItems]
|
||||
|
|
@ -150,6 +154,7 @@ export function Sidebar({
|
|||
(item) =>
|
||||
!item.url.endsWith("/automations") &&
|
||||
!item.url.endsWith("/artifacts") &&
|
||||
!item.url.endsWith("/connectors") &&
|
||||
!item.url.endsWith("/playground")
|
||||
),
|
||||
[navItems]
|
||||
|
|
@ -254,6 +259,16 @@ export function Sidebar({
|
|||
tooltipContent={isCollapsed ? artifactsItem.title : undefined}
|
||||
/>
|
||||
)}
|
||||
{connectorsItem && (
|
||||
<SidebarButton
|
||||
icon={connectorsItem.icon}
|
||||
label={connectorsItem.title}
|
||||
onClick={() => onNavItemClick?.(connectorsItem)}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={connectorsItem.isActive}
|
||||
tooltipContent={isCollapsed ? connectorsItem.title : undefined}
|
||||
/>
|
||||
)}
|
||||
{playgroundItem && (
|
||||
<SidebarButton
|
||||
icon={playgroundItem.icon}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ interface PublicChatSnapshotsManagerProps {
|
|||
workspaceId: number;
|
||||
}
|
||||
|
||||
const infoAlert = (
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
Public chats allow anyone with the URL to view a snapshot of a chat. They do not update when
|
||||
the original chat changes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
export function PublicChatSnapshotsManager({
|
||||
workspaceId: _workspaceId,
|
||||
}: PublicChatSnapshotsManagerProps) {
|
||||
|
|
@ -81,14 +91,7 @@ export function PublicChatSnapshotsManager({
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-5">
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
<div className="flex min-h-[1.625em] items-center">
|
||||
<Skeleton className="h-4 w-60 bg-accent-foreground/15" />
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{infoAlert}
|
||||
|
||||
{/* Cards grid skeleton */}
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2">
|
||||
|
|
@ -135,13 +138,7 @@ export function PublicChatSnapshotsManager({
|
|||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-5">
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
Public chats allow anyone with the URL to view a snapshot of a chat. They do not update
|
||||
when the original chat changes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{infoAlert}
|
||||
|
||||
<PublicChatSnapshotsList
|
||||
snapshots={snapshots}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { Check, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -15,11 +14,7 @@ import { Spinner } from "@/components/ui/spinner";
|
|||
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
||||
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import {
|
||||
trackIncentivePageViewed,
|
||||
trackIncentiveTaskClicked,
|
||||
trackIncentiveTaskCompleted,
|
||||
} from "@/lib/posthog/events";
|
||||
import { trackIncentiveTaskClicked } from "@/lib/posthog/events";
|
||||
import { getWorkspaceIdParam } from "@/lib/route-params";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -35,9 +30,7 @@ export function EarnCreditsContent() {
|
|||
const queryClient = useQueryClient();
|
||||
const workspaceId = getWorkspaceIdParam(params) ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
trackIncentivePageViewed();
|
||||
}, []);
|
||||
// incentive_page_viewed removed — redundant with $pageview.
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["incentive-tasks"],
|
||||
|
|
@ -51,13 +44,11 @@ export function EarnCreditsContent() {
|
|||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: incentiveTasksApiService.completeTask,
|
||||
onSuccess: (response, taskType) => {
|
||||
onSuccess: (response) => {
|
||||
if (response.success) {
|
||||
toast.success(response.message);
|
||||
const task = data?.tasks.find((t) => t.task_type === taskType);
|
||||
if (task) {
|
||||
trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
|
||||
}
|
||||
// incentive_task_completed is now emitted server-side
|
||||
// (incentive_tasks_routes.complete_task) where credit is granted.
|
||||
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
||||
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,11 +29,7 @@ import { Switch } from "@/components/ui/switch";
|
|||
import type { ProcessingMode } from "@/contracts/types/document.types";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import {
|
||||
trackDocumentUploadFailure,
|
||||
trackDocumentUploadStarted,
|
||||
trackDocumentUploadSuccess,
|
||||
} from "@/lib/posthog/events";
|
||||
import { trackDocumentUploadStarted } from "@/lib/posthog/events";
|
||||
import {
|
||||
getAcceptedFileTypes,
|
||||
getSupportedExtensions,
|
||||
|
|
@ -380,13 +376,14 @@ export function DocumentUploadTab({
|
|||
setUploadProgress(Math.round((uploaded / total) * 100));
|
||||
}
|
||||
|
||||
trackDocumentUploadSuccess(Number(workspaceId), total);
|
||||
// Ingestion outcome is now emitted server-side
|
||||
// (document_processing_completed/_failed in document_tasks.py); the
|
||||
// upload POST succeeding only means the file was accepted, not processed.
|
||||
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
|
||||
setFolderUpload(null);
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Upload failed";
|
||||
trackDocumentUploadFailure(Number(workspaceId), message);
|
||||
toast(t("upload_error"), {
|
||||
description: `${t("upload_error_desc")}: ${message}`,
|
||||
});
|
||||
|
|
@ -421,7 +418,7 @@ export function DocumentUploadTab({
|
|||
onSuccess: () => {
|
||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
setUploadProgress(100);
|
||||
trackDocumentUploadSuccess(Number(workspaceId), files.length);
|
||||
// Ingestion outcome now server-side (document_processing_*).
|
||||
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
|
||||
onSuccess?.();
|
||||
},
|
||||
|
|
@ -429,7 +426,6 @@ export function DocumentUploadTab({
|
|||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||
setUploadProgress(0);
|
||||
const message = error instanceof Error ? error.message : "Upload failed";
|
||||
trackDocumentUploadFailure(Number(workspaceId), message);
|
||||
toast(t("upload_error"), {
|
||||
description: `${t("upload_error_desc")}: ${message}`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ Connectors bring data into SurfSense — either as searchable knowledge or as li
|
|||
## How connectors surface in SurfSense
|
||||
|
||||
- **Native scrapers** — the AI agent uses them as tools automatically in chat, and you can run them yourself from the **API Playground** or your own code via the REST API.
|
||||
- **Tools & Live Sources** — external connectors like Notion, Slack, Jira, or Linear give the agent real-time tools. Ask a question in chat and the agent searches or acts on the connected service directly; nothing is copied into SurfSense in the background.
|
||||
- **Integrations** — external connectors like Notion, Slack, Jira, or Linear give the agent real-time tools. Ask a question in chat and the agent searches or acts on the connected service directly; nothing is copied into SurfSense in the background.
|
||||
- **Knowledge Base** — file sources like Google Drive, OneDrive, and Dropbox are imported through the Documents sidebar's **Import** menu and indexed into your knowledge base alongside uploads and notes.
|
||||
|
||||
To manage external connectors, open the **Connectors** dialog inside your workspace. Connected accounts, sync status, and disconnect options all live there.
|
||||
|
|
|
|||
|
|
@ -156,6 +156,40 @@ transport.
|
|||
Celery runtime, and runtime gauges appear within one export interval.
|
||||
6. Confirm logs emitted inside a traced request show non-zero trace and span IDs.
|
||||
|
||||
## Product Analytics (PostHog)
|
||||
|
||||
Separate from OpenTelemetry, the backend can emit server-side product events to
|
||||
PostHog. This is the authoritative source for outcome events (chats, document
|
||||
ingestion, connector indexing, billing, automations) because it captures traffic
|
||||
the browser never sees — MCP clients, personal-access-token scripts, and Celery
|
||||
background jobs. It is fully opt-in and mirrors the OTel contract: with
|
||||
`POSTHOG_API_KEY` unset, every capture is a silent no-op.
|
||||
|
||||
Use the **same** project key as the frontend's `NEXT_PUBLIC_POSTHOG_KEY` so
|
||||
server events merge onto the same PostHog persons the web app identifies by user
|
||||
id. Add these to `surfsense_backend/.env` (local) or `docker/.env` (production);
|
||||
they reach the API, Celery worker, and beat services via `env_file`, so no
|
||||
compose changes are needed:
|
||||
|
||||
```dotenv
|
||||
POSTHOG_API_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
POSTHOG_HOST=https://us.i.posthog.com
|
||||
POSTHOG_AI_PRIVACY_MODE=true
|
||||
```
|
||||
|
||||
`POSTHOG_AI_PRIVACY_MODE` defaults to `true`; set it to `false` only if you want
|
||||
LLM prompt and completion bodies shipped to PostHog's AI observability views.
|
||||
|
||||
Every backend event is stamped `source=backend` (so it is distinguishable from
|
||||
frontend captures), carries `auth_method` / `client` for surface attribution, and
|
||||
sends `disable_geoip=true` so the server IP never overwrites a person's real
|
||||
location. LangGraph chat turns additionally emit `$ai_generation` / `$ai_span`
|
||||
traces via the PostHog LangChain callback handler, keyed by turn and chat id.
|
||||
|
||||
Keep event properties low-cardinality. Never attach user content — workspace
|
||||
names, connector titles, document titles, prompts, or raw queries — as event
|
||||
properties; they carry no aggregation value and are a privacy risk.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Frontend/browser OpenTelemetry.
|
||||
|
|
|
|||
|
|
@ -74,7 +74,17 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
|||
case EnumConnectorName.CIRCLEBACK_CONNECTOR:
|
||||
return <Image src="/connectors/circleback.svg" alt="Circleback" {...imgProps} />;
|
||||
case EnumConnectorName.MCP_CONNECTOR:
|
||||
return <Image src="/connectors/modelcontextprotocol.svg" alt="MCP" {...imgProps} />;
|
||||
// Masked so the black glyph inherits currentColor (white in dark mode).
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${className || "h-5 w-5"} bg-current select-none pointer-events-none`}
|
||||
style={{
|
||||
mask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
|
||||
WebkitMask: "url('/connectors/modelcontextprotocol.svg') center / contain no-repeat",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case EnumConnectorName.OBSIDIAN_CONNECTOR:
|
||||
return <Image src="/connectors/obsidian.svg" alt="Obsidian" {...imgProps} />;
|
||||
case EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useSetAtom } from "jotai";
|
||||
import { Boxes, RefreshCw, TriangleAlert } from "lucide-react";
|
||||
import { RefreshCw, Shapes, TriangleAlert } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { MobileReportPanel } from "@/components/report-panel/report-panel";
|
||||
|
|
@ -48,7 +48,7 @@ function EmptyState() {
|
|||
return (
|
||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Boxes className="h-6 w-6" aria-hidden />
|
||||
<Shapes className="h-6 w-6" aria-hidden />
|
||||
</div>
|
||||
<h3 className="mt-4 text-base font-semibold text-foreground">No artifacts yet</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground max-w-md mx-auto">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ScraperRunDetail, ScraperRunEvent } from "@/contracts/types/scraper.types";
|
||||
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
|
||||
import { trackWeeklyUser } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
export type RunStatus = "idle" | "running" | "success" | "error" | "cancelled";
|
||||
|
|
@ -110,7 +109,8 @@ export function useRunStream(workspaceId: number) {
|
|||
try {
|
||||
const started = await scrapersApiService.runAsync(workspaceId, platform, verb, payload);
|
||||
runIdRef.current = started.run_id;
|
||||
trackWeeklyUser("api_run", workspaceId);
|
||||
// weekly_users removed — WAU is derived server-side from
|
||||
// scraper_run_completed / chat_turn_completed.
|
||||
setState((s) => ({ ...s, runId: started.run_id }));
|
||||
void consume(started.run_id, controller.signal);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,30 @@ function blockRefreshRetry(key: string): void {
|
|||
refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an API failure to PostHog error tracking. Scoped by the caller to only
|
||||
* 5xx server faults + network outages — 4xx responses are expected behavior.
|
||||
* Lazy-imports posthog-js so an ad-blocker can never break the request path.
|
||||
*/
|
||||
function captureApiException(error: unknown, url: string, method?: RequestOptions["method"]): void {
|
||||
import("posthog-js")
|
||||
.then(({ default: posthog }) => {
|
||||
posthog.captureException(error, {
|
||||
api_url: url,
|
||||
api_method: method ?? "GET",
|
||||
...(error instanceof AppError && {
|
||||
status_code: error.status,
|
||||
status_text: error.statusText,
|
||||
error_code: error.code,
|
||||
request_id: error.requestId,
|
||||
}),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
console.error("Failed to capture exception in PostHog");
|
||||
});
|
||||
}
|
||||
|
||||
export type RequestOptions = {
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
headers?: Record<string, string>;
|
||||
|
|
@ -288,9 +312,12 @@ class BaseApiService {
|
|||
throw new AbortedError();
|
||||
}
|
||||
if (error instanceof TypeError && !(error instanceof AppError)) {
|
||||
throw new NetworkError(
|
||||
const networkError = new NetworkError(
|
||||
"Unable to connect to the server. Check your internet connection and try again."
|
||||
);
|
||||
// Network failures are genuine outages worth tracking.
|
||||
captureApiException(networkError, url, options?.method);
|
||||
throw networkError;
|
||||
}
|
||||
|
||||
// Handled client errors (validation, credits, auth, not-found, ...) are
|
||||
|
|
@ -305,23 +332,10 @@ class BaseApiService {
|
|||
|
||||
if (!isHandledClientError) {
|
||||
console.error("Request failed:", JSON.stringify(error));
|
||||
if (!(error instanceof AuthenticationError)) {
|
||||
import("posthog-js")
|
||||
.then(({ default: posthog }) => {
|
||||
posthog.captureException(error, {
|
||||
api_url: url,
|
||||
api_method: options?.method ?? "GET",
|
||||
...(error instanceof AppError && {
|
||||
status_code: error.status,
|
||||
status_text: error.statusText,
|
||||
error_code: error.code,
|
||||
request_id: error.requestId,
|
||||
}),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
console.error("Failed to capture exception in PostHog");
|
||||
});
|
||||
// Only 5xx server faults are unexpected; 4xx are handled above and
|
||||
// network outages were already captured before this point.
|
||||
if (error instanceof AppError && error.status >= 500) {
|
||||
captureApiException(error, url, options?.method);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ import {
|
|||
import { buildBackendUrl } from "@/lib/env-config";
|
||||
import {
|
||||
trackChatBlocked,
|
||||
trackChatCreated,
|
||||
trackChatErrorDetailed,
|
||||
trackChatMessageSent,
|
||||
trackChatResponseReceived,
|
||||
|
|
@ -384,7 +383,8 @@ export async function startNewChat(ctx: EngineContext, message: AppendMessage):
|
|||
queryClient.setQueryData(cacheKeys.threads.detail(newThread.id), newThread);
|
||||
queryClient.setQueryData(cacheKeys.threads.messages(newThread.id), { messages: [] });
|
||||
|
||||
trackChatCreated(workspaceId, currentThreadId);
|
||||
// chat_created is now emitted server-side (new_chat_routes.create_thread)
|
||||
// so PAT/MCP-created threads are also counted.
|
||||
|
||||
isNewThread = true;
|
||||
// Update URL silently using browser API (not router.replace) to avoid
|
||||
|
|
|
|||
|
|
@ -3,24 +3,20 @@ import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat
|
|||
import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry";
|
||||
|
||||
/**
|
||||
* PostHog Analytics Event Definitions
|
||||
* PostHog Analytics Event Definitions (frontend)
|
||||
*
|
||||
* All capture/identify/reset calls are wrapped in try-catch so that
|
||||
* ad-blockers that interfere with posthog-js can never break app
|
||||
* functionality (e.g. the chat flow).
|
||||
*
|
||||
* Events follow a consistent naming convention: category_action
|
||||
* SCOPE: this file now holds only *intent* and client-perceived *UX* events.
|
||||
* Authoritative *outcome* events (resource creation, task/ingestion/indexing
|
||||
* completion, auth success, billing) are emitted server-side in
|
||||
* surfsense_backend/app/observability/analytics.py — they are reliable
|
||||
* regardless of ad-blockers, tab-close, or non-browser (MCP/PAT/OAuth)
|
||||
* clients. Do NOT re-add optimistic outcome captures here; they double-count.
|
||||
*
|
||||
* Categories:
|
||||
* - auth: Authentication events
|
||||
* - workspace: Search space management
|
||||
* - document: Document management
|
||||
* - chat: Chat and messaging (authenticated + anonymous)
|
||||
* - connector: External connector events (all lifecycle stages)
|
||||
* - contact: Contact form events
|
||||
* - settings: Settings changes
|
||||
* - automation: Automation lifecycle (create/update/delete/trigger/chat)
|
||||
* - marketing: Marketing/referral tracking
|
||||
* Events follow a consistent naming convention: category_action
|
||||
*/
|
||||
|
||||
function safeCapture(event: string, properties?: Record<string, unknown>) {
|
||||
|
|
@ -43,17 +39,13 @@ function compact<T extends object>(obj: T): Record<string, unknown> {
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// AUTH EVENTS
|
||||
// AUTH EVENTS (attempts + failures only; successes are server-side)
|
||||
// ============================================
|
||||
|
||||
export function trackLoginAttempt(method: "local" | "google") {
|
||||
safeCapture("auth_login_attempt", { method });
|
||||
}
|
||||
|
||||
export function trackLoginSuccess(method: "local" | "google") {
|
||||
safeCapture("auth_login_success", { method });
|
||||
}
|
||||
|
||||
export function trackLoginFailure(method: "local" | "google", error?: string) {
|
||||
safeCapture("auth_login_failure", { method, error });
|
||||
}
|
||||
|
|
@ -62,10 +54,6 @@ export function trackRegistrationAttempt() {
|
|||
safeCapture("auth_registration_attempt");
|
||||
}
|
||||
|
||||
export function trackRegistrationSuccess() {
|
||||
safeCapture("auth_registration_success");
|
||||
}
|
||||
|
||||
export function trackRegistrationFailure(error?: string) {
|
||||
safeCapture("auth_registration_failure", { error });
|
||||
}
|
||||
|
|
@ -75,56 +63,9 @@ export function trackLogout() {
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// SEARCH SPACE EVENTS
|
||||
// CHAT EVENTS (client-perceived UX)
|
||||
// ============================================
|
||||
|
||||
export function trackWorkspaceCreated(workspaceId: number, name: string) {
|
||||
safeCapture("workspace_created", {
|
||||
workspace_id: workspaceId,
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackWorkspaceDeleted(workspaceId: number) {
|
||||
safeCapture("workspace_deleted", {
|
||||
workspace_id: workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackWorkspaceViewed(workspaceId: number) {
|
||||
safeCapture("workspace_viewed", {
|
||||
workspace_id: workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ACTIVE-USER (WAU) EVENT
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Single signal for active-user counting. Fired whenever a user sends a
|
||||
* chat message or starts an API run, so a "weekly unique users on
|
||||
* weekly_users" insight in PostHog is our WAU number.
|
||||
*
|
||||
* ponytail: frontend-only capture — API runs made directly against the
|
||||
* backend (PAT/curl, no browser) are not counted. Upgrade path is a
|
||||
* server-side capture in the backend if that ever matters.
|
||||
*/
|
||||
export function trackWeeklyUser(source: "chat_message" | "api_run", workspaceId?: number) {
|
||||
safeCapture("weekly_users", compact({ source, workspace_id: workspaceId }));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CHAT EVENTS
|
||||
// ============================================
|
||||
|
||||
export function trackChatCreated(workspaceId: number, chatId: number) {
|
||||
safeCapture("chat_created", {
|
||||
workspace_id: workspaceId,
|
||||
chat_id: chatId,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackChatMessageSent(
|
||||
workspaceId: number,
|
||||
chatId: number,
|
||||
|
|
@ -141,7 +82,6 @@ export function trackChatMessageSent(
|
|||
has_mentioned_documents: options?.hasMentionedDocuments ?? false,
|
||||
message_length: options?.messageLength,
|
||||
});
|
||||
trackWeeklyUser("chat_message", workspaceId);
|
||||
}
|
||||
|
||||
export function trackChatResponseReceived(workspaceId: number, chatId: number) {
|
||||
|
|
@ -213,6 +153,10 @@ export function trackChatErrorDetailed(
|
|||
* 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/*.
|
||||
*
|
||||
* Kept frontend-side despite the backend's `anon_chat_turn_completed`: the
|
||||
* frontend anon distinct id is what merges into the person at signup,
|
||||
* powering the anonymous-to-registered conversion funnel.
|
||||
*/
|
||||
export function trackAnonymousChatMessageSent(options: {
|
||||
modelSlug: string;
|
||||
|
|
@ -229,7 +173,7 @@ export function trackAnonymousChatMessageSent(options: {
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// DOCUMENT EVENTS
|
||||
// DOCUMENT EVENTS (intent only; ingestion outcome is server-side)
|
||||
// ============================================
|
||||
|
||||
export function trackDocumentUploadStarted(
|
||||
|
|
@ -244,59 +188,20 @@ export function trackDocumentUploadStarted(
|
|||
});
|
||||
}
|
||||
|
||||
export function trackDocumentUploadSuccess(workspaceId: number, fileCount: number) {
|
||||
safeCapture("document_upload_success", {
|
||||
workspace_id: workspaceId,
|
||||
file_count: fileCount,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackDocumentUploadFailure(workspaceId: number, error?: string) {
|
||||
safeCapture("document_upload_failure", {
|
||||
workspace_id: workspaceId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackDocumentDeleted(workspaceId: number, documentId: number) {
|
||||
safeCapture("document_deleted", {
|
||||
workspace_id: workspaceId,
|
||||
document_id: documentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackDocumentBulkDeleted(workspaceId: number, count: number) {
|
||||
safeCapture("document_bulk_deleted", {
|
||||
workspace_id: workspaceId,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackYouTubeImport(workspaceId: number, url: string) {
|
||||
safeCapture("youtube_import_started", {
|
||||
workspace_id: workspaceId,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CONNECTOR EVENTS (generic lifecycle dispatcher)
|
||||
// CONNECTOR EVENTS (setup intent/UX; connected/deleted are server-side)
|
||||
// ============================================
|
||||
//
|
||||
// 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.
|
||||
// group (oauth/composio/crawler/other) is auto-attached from the shared
|
||||
// registry, so adding a new connector to that list is the only change
|
||||
// required for it to show up correctly in PostHog dashboards.
|
||||
|
||||
export type ConnectorEventStage =
|
||||
| "setup_started"
|
||||
| "setup_success"
|
||||
| "setup_failure"
|
||||
| "oauth_initiated"
|
||||
| "connected"
|
||||
| "deleted"
|
||||
| "synced";
|
||||
| "oauth_initiated";
|
||||
|
||||
export interface ConnectorEventOptions {
|
||||
workspaceId?: number | null;
|
||||
|
|
@ -312,6 +217,9 @@ export interface ConnectorEventOptions {
|
|||
/**
|
||||
* Generic connector lifecycle tracker. Every connector analytics event
|
||||
* should funnel through here so the enrichment stays consistent.
|
||||
*
|
||||
* ``connector_title`` is intentionally NOT sent — it's a display label with
|
||||
* no aggregation value; segment on ``connector_type`` / ``connector_group``.
|
||||
*/
|
||||
export function trackConnectorEvent(
|
||||
stage: ConnectorEventStage,
|
||||
|
|
@ -327,7 +235,6 @@ export function trackConnectorEvent(
|
|||
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 ?? {}),
|
||||
|
|
@ -365,59 +272,10 @@ export function trackConnectorSetupFailure(
|
|||
});
|
||||
}
|
||||
|
||||
export function trackConnectorDeleted(
|
||||
workspaceId: number,
|
||||
connectorType: string,
|
||||
connectorId: number
|
||||
) {
|
||||
trackConnectorEvent("deleted", connectorType, { workspaceId, connectorId });
|
||||
}
|
||||
|
||||
export function trackConnectorSynced(
|
||||
workspaceId: number,
|
||||
connectorType: string,
|
||||
connectorId: number
|
||||
) {
|
||||
trackConnectorEvent("synced", connectorType, { workspaceId, connectorId });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SETTINGS EVENTS
|
||||
// ============================================
|
||||
|
||||
export function trackSettingsViewed(workspaceId: number, section: string) {
|
||||
safeCapture("settings_viewed", {
|
||||
workspace_id: workspaceId,
|
||||
section,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackSettingsUpdated(workspaceId: number, section: string, setting: string) {
|
||||
safeCapture("settings_updated", {
|
||||
workspace_id: workspaceId,
|
||||
section,
|
||||
setting,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FEATURE USAGE EVENTS
|
||||
// ============================================
|
||||
|
||||
export function trackPodcastGenerated(workspaceId: number, chatId: number) {
|
||||
safeCapture("podcast_generated", {
|
||||
workspace_id: workspaceId,
|
||||
chat_id: chatId,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackSourcesTabViewed(workspaceId: number, tab: string) {
|
||||
safeCapture("sources_tab_viewed", {
|
||||
workspace_id: workspaceId,
|
||||
tab,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackDesktopDownloadClicked(options: {
|
||||
os: string;
|
||||
placement: "sidebar_collapsed" | "sidebar_expanded";
|
||||
|
|
@ -429,84 +287,7 @@ export function trackDesktopDownloadClicked(options: {
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// SEARCH SPACE INVITE EVENTS
|
||||
// ============================================
|
||||
|
||||
export function trackWorkspaceInviteSent(
|
||||
workspaceId: number,
|
||||
options?: {
|
||||
roleName?: string;
|
||||
hasExpiry?: boolean;
|
||||
hasMaxUses?: boolean;
|
||||
}
|
||||
) {
|
||||
safeCapture("workspace_invite_sent", {
|
||||
workspace_id: workspaceId,
|
||||
role_name: options?.roleName,
|
||||
has_expiry: options?.hasExpiry ?? false,
|
||||
has_max_uses: options?.hasMaxUses ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackWorkspaceInviteAccepted(
|
||||
workspaceId: number,
|
||||
workspaceName: string,
|
||||
roleName?: string | null
|
||||
) {
|
||||
safeCapture("workspace_invite_accepted", {
|
||||
workspace_id: workspaceId,
|
||||
workspace_name: workspaceName,
|
||||
role_name: roleName,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackWorkspaceInviteDeclined(workspaceName?: string) {
|
||||
safeCapture("workspace_invite_declined", {
|
||||
workspace_name: workspaceName,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackWorkspaceUserAdded(
|
||||
workspaceId: number,
|
||||
workspaceName: string,
|
||||
roleName?: string | null
|
||||
) {
|
||||
safeCapture("workspace_user_added", {
|
||||
workspace_id: workspaceId,
|
||||
workspace_name: workspaceName,
|
||||
role_name: roleName,
|
||||
});
|
||||
}
|
||||
|
||||
export function trackWorkspaceUsersViewed(
|
||||
workspaceId: number,
|
||||
userCount: number,
|
||||
ownerCount: number
|
||||
) {
|
||||
safeCapture("workspace_users_viewed", {
|
||||
workspace_id: workspaceId,
|
||||
user_count: userCount,
|
||||
owner_count: ownerCount,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CONNECTOR CONNECTION EVENTS
|
||||
// ============================================
|
||||
|
||||
export function trackConnectorConnected(
|
||||
workspaceId: number,
|
||||
connectorType: string,
|
||||
connectorId?: number
|
||||
) {
|
||||
trackConnectorEvent("connected", connectorType, {
|
||||
workspaceId,
|
||||
connectorId: connectorId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INDEXING EVENTS
|
||||
// INDEXING EVENTS (intent/UX; indexing outcome is server-side)
|
||||
// ============================================
|
||||
|
||||
export function trackIndexWithDateRangeOpened(
|
||||
|
|
@ -578,20 +359,19 @@ export function trackPeriodicIndexingStarted(
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// INCENTIVE TASKS EVENTS
|
||||
// SEARCH SPACE INVITE EVENTS (decline is client-only; sent/accepted server-side)
|
||||
// ============================================
|
||||
|
||||
export function trackIncentivePageViewed() {
|
||||
safeCapture("incentive_page_viewed");
|
||||
}
|
||||
|
||||
export function trackIncentiveTaskCompleted(taskType: string, creditMicrosRewarded: number) {
|
||||
safeCapture("incentive_task_completed", {
|
||||
task_type: taskType,
|
||||
credit_micros_rewarded: creditMicrosRewarded,
|
||||
export function trackWorkspaceInviteDeclined(workspaceName?: string) {
|
||||
safeCapture("workspace_invite_declined", {
|
||||
workspace_name: workspaceName,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INCENTIVE TASKS EVENTS (click intent only; completion is server-side)
|
||||
// ============================================
|
||||
|
||||
export function trackIncentiveTaskClicked(taskType: string) {
|
||||
safeCapture("incentive_task_clicked", {
|
||||
task_type: taskType,
|
||||
|
|
@ -616,83 +396,25 @@ export function trackReferralLanding(refCode: string, landingUrl: string) {
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// AUTOMATION EVENTS
|
||||
// AUTOMATION EVENTS (failures + chat-builder UX; CRUD outcomes are server-side)
|
||||
// ============================================
|
||||
|
||||
interface AutomationCreatedProps {
|
||||
workspace_id: number;
|
||||
automation_id: number;
|
||||
task_count?: number;
|
||||
trigger_type?: string;
|
||||
has_schedule?: boolean;
|
||||
chat_model_id?: number;
|
||||
image_gen_model_id?: number;
|
||||
vision_model_id?: number;
|
||||
tags_count?: number;
|
||||
}
|
||||
|
||||
export function trackAutomationCreated(props: AutomationCreatedProps) {
|
||||
safeCapture("automation_created", compact(props));
|
||||
}
|
||||
|
||||
export function trackAutomationCreateFailed(props: { workspace_id?: number; error?: string }) {
|
||||
safeCapture("automation_create_failed", compact(props));
|
||||
}
|
||||
|
||||
export function trackAutomationUpdated(props: {
|
||||
automation_id: number;
|
||||
workspace_id?: number;
|
||||
has_definition_change?: boolean;
|
||||
has_name_change?: boolean;
|
||||
has_description_change?: boolean;
|
||||
task_count?: number;
|
||||
}) {
|
||||
safeCapture("automation_updated", compact(props));
|
||||
}
|
||||
|
||||
export function trackAutomationStatusChanged(props: {
|
||||
automation_id: number;
|
||||
workspace_id?: number;
|
||||
next_status: string;
|
||||
}) {
|
||||
safeCapture("automation_status_changed", compact(props));
|
||||
}
|
||||
|
||||
export function trackAutomationUpdateFailed(props: { automation_id: number; error?: string }) {
|
||||
safeCapture("automation_update_failed", compact(props));
|
||||
}
|
||||
|
||||
export function trackAutomationDeleted(props: { automation_id: number; workspace_id?: number }) {
|
||||
safeCapture("automation_deleted", compact(props));
|
||||
}
|
||||
|
||||
export function trackAutomationDeleteFailed(props: { automation_id: number; error?: string }) {
|
||||
safeCapture("automation_delete_failed", compact(props));
|
||||
}
|
||||
|
||||
export function trackAutomationTriggerAdded(props: {
|
||||
automation_id: number;
|
||||
trigger_id?: number;
|
||||
trigger_type?: string;
|
||||
enabled?: boolean;
|
||||
has_cron?: boolean;
|
||||
}) {
|
||||
safeCapture("automation_trigger_added", compact(props));
|
||||
}
|
||||
|
||||
export function trackAutomationTriggerAddFailed(props: { automation_id: number; error?: string }) {
|
||||
safeCapture("automation_trigger_add_failed", compact(props));
|
||||
}
|
||||
|
||||
export function trackAutomationTriggerUpdated(props: {
|
||||
automation_id: number;
|
||||
trigger_id: number;
|
||||
change?: "enabled" | "params" | "other";
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
safeCapture("automation_trigger_updated", compact(props));
|
||||
}
|
||||
|
||||
export function trackAutomationTriggerUpdateFailed(props: {
|
||||
automation_id: number;
|
||||
trigger_id: number;
|
||||
|
|
@ -701,13 +423,6 @@ export function trackAutomationTriggerUpdateFailed(props: {
|
|||
safeCapture("automation_trigger_update_failed", compact(props));
|
||||
}
|
||||
|
||||
export function trackAutomationTriggerRemoved(props: {
|
||||
automation_id: number;
|
||||
trigger_id: number;
|
||||
}) {
|
||||
safeCapture("automation_trigger_removed", compact(props));
|
||||
}
|
||||
|
||||
export function trackAutomationTriggerRemoveFailed(props: {
|
||||
automation_id: number;
|
||||
trigger_id: number;
|
||||
|
|
|
|||
|
|
@ -664,7 +664,6 @@
|
|||
"chat_deleted": "Chat deleted successfully",
|
||||
"error_deleting_chat": "Failed to delete chat",
|
||||
"delete": "Delete",
|
||||
"try_different_search": "Try a different search term",
|
||||
"updated": "Updated",
|
||||
"more_options": "More options",
|
||||
"clear_search": "Clear search",
|
||||
|
|
|
|||
|
|
@ -664,7 +664,6 @@
|
|||
"chat_deleted": "Chat eliminado correctamente",
|
||||
"error_deleting_chat": "Error al eliminar el chat",
|
||||
"delete": "Eliminar",
|
||||
"try_different_search": "Intenta con un término de búsqueda diferente",
|
||||
"updated": "Actualizado",
|
||||
"more_options": "Más opciones",
|
||||
"clear_search": "Limpiar búsqueda",
|
||||
|
|
|
|||
|
|
@ -664,7 +664,6 @@
|
|||
"chat_deleted": "चैट सफलतापूर्वक हटाया गया",
|
||||
"error_deleting_chat": "चैट हटाने में विफल",
|
||||
"delete": "हटाएं",
|
||||
"try_different_search": "कोई अलग खोज शब्द आज़माएं",
|
||||
"updated": "अपडेट किया गया",
|
||||
"more_options": "और विकल्प",
|
||||
"clear_search": "खोज साफ करें",
|
||||
|
|
|
|||
|
|
@ -664,7 +664,6 @@
|
|||
"chat_deleted": "채팅이 성공적으로 삭제되었습니다",
|
||||
"error_deleting_chat": "채팅 삭제에 실패했습니다",
|
||||
"delete": "삭제",
|
||||
"try_different_search": "다른 검색어를 시도해보세요",
|
||||
"updated": "업데이트됨",
|
||||
"more_options": "추가 옵션",
|
||||
"clear_search": "검색 지우기",
|
||||
|
|
|
|||
|
|
@ -664,7 +664,6 @@
|
|||
"chat_deleted": "Chat excluído com sucesso",
|
||||
"error_deleting_chat": "Falha ao excluir chat",
|
||||
"delete": "Excluir",
|
||||
"try_different_search": "Tente um termo de pesquisa diferente",
|
||||
"updated": "Atualizado",
|
||||
"more_options": "Mais opções",
|
||||
"clear_search": "Limpar pesquisa",
|
||||
|
|
|
|||
|
|
@ -663,7 +663,6 @@
|
|||
"chat_deleted": "对话删除成功",
|
||||
"error_deleting_chat": "删除对话失败",
|
||||
"delete": "删除",
|
||||
"try_different_search": "尝试其他搜索词",
|
||||
"updated": "更新时间",
|
||||
"more_options": "更多选项",
|
||||
"clear_search": "清除搜索",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue