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_HTTP_PORT=4318
|
||||||
# OTEL_HEALTH_PORT=13133
|
# 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)
|
# Advanced (optional)
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -555,6 +555,14 @@ LANGSMITH_PROJECT=surfsense
|
||||||
# OTEL_METRIC_EXPORT_INTERVAL=300000 # ms; 5 minutes
|
# OTEL_METRIC_EXPORT_INTERVAL=300000 # ms; 5 minutes
|
||||||
# OTEL_SDK_DISABLED=true # emergency kill-switch
|
# 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
|
# Skills + subagents
|
||||||
# SURFSENSE_ENABLE_SKILLS=false
|
# SURFSENSE_ENABLE_SKILLS=false
|
||||||
# SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=false
|
# SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=false
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ from app.gateway.inbox_worker import (
|
||||||
start_gateway_inbox_worker,
|
start_gateway_inbox_worker,
|
||||||
stop_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 import metrics as ot_metrics
|
||||||
from app.observability.bootstrap import init_otel, shutdown_otel
|
from app.observability.bootstrap import init_otel, shutdown_otel
|
||||||
from app.rate_limiter import get_real_client_ip, limiter
|
from app.rate_limiter import get_real_client_ip, limiter
|
||||||
|
|
@ -714,6 +715,7 @@ async def lifespan(app: FastAPI):
|
||||||
await stop_gateway_inbox_worker()
|
await stop_gateway_inbox_worker()
|
||||||
_stop_openrouter_background_refresh()
|
_stop_openrouter_background_refresh()
|
||||||
await close_checkpointer()
|
await close_checkpointer()
|
||||||
|
ph_analytics.shutdown()
|
||||||
shutdown_otel()
|
shutdown_otel()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -820,6 +822,56 @@ class RequestPerfMiddleware(BaseHTTPMiddleware):
|
||||||
|
|
||||||
app.add_middleware(RequestPerfMiddleware)
|
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
|
# Add SlowAPI middleware for automatic rate limiting
|
||||||
# Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid
|
# Uses Starlette BaseHTTPMiddleware (not the raw ASGI variant) to avoid
|
||||||
# corrupting StreamingResponse — SlowAPIASGIMiddleware re-sends
|
# 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.schemas.definition.plan_step import PlanStep
|
||||||
from app.automations.templating import build_run_context
|
from app.automations.templating import build_run_context
|
||||||
|
from app.observability import analytics as ph_analytics
|
||||||
|
|
||||||
from . import repository
|
from . import repository
|
||||||
from .step import execute_step
|
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:
|
async def execute_run(session: AsyncSession, run_id: int) -> None:
|
||||||
"""Load run ``run_id`` and execute its snapshot plan to a terminal state."""
|
"""Load run ``run_id`` and execute its snapshot plan to a terminal state."""
|
||||||
run = await repository.load_run(session, run_id)
|
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()
|
await session.commit()
|
||||||
|
_capture_run_outcome(run, "failed")
|
||||||
return
|
return
|
||||||
|
|
||||||
await repository.mark_running(session, run)
|
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 _run_on_failure(session, run, definition)
|
||||||
await repository.mark_failed(session, run, result.get("error"))
|
await repository.mark_failed(session, run, result.get("error"))
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
_capture_run_outcome(run, "failed")
|
||||||
return
|
return
|
||||||
|
|
||||||
if result["status"] == "succeeded":
|
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 repository.mark_succeeded(session, run)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
_capture_run_outcome(run, "succeeded")
|
||||||
|
|
||||||
|
|
||||||
async def _run_on_failure(
|
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 import get_trigger
|
||||||
from app.automations.triggers.builtin.schedule import compute_next_fire_at
|
from app.automations.triggers.builtin.schedule import compute_next_fire_at
|
||||||
from app.db import Permission, Workspace, get_async_session
|
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.users import get_auth_context
|
||||||
from app.utils.rbac import check_permission
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
|
|
@ -75,6 +76,18 @@ class AutomationService:
|
||||||
|
|
||||||
self.session.add(automation)
|
self.session.add(automation)
|
||||||
await self.session.commit()
|
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)
|
return await self._get_with_triggers_or_raise(automation.id)
|
||||||
|
|
||||||
async def list(
|
async def list(
|
||||||
|
|
@ -150,6 +163,31 @@ class AutomationService:
|
||||||
automation.version += 1
|
automation.version += 1
|
||||||
|
|
||||||
await self.session.commit()
|
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)
|
return await self._get_with_triggers_or_raise(automation_id)
|
||||||
|
|
||||||
async def delete(self, automation_id: int) -> None:
|
async def delete(self, automation_id: int) -> None:
|
||||||
|
|
@ -158,9 +196,18 @@ class AutomationService:
|
||||||
await self._authorize(
|
await self._authorize(
|
||||||
automation.workspace_id, Permission.AUTOMATIONS_DELETE.value
|
automation.workspace_id, Permission.AUTOMATIONS_DELETE.value
|
||||||
)
|
)
|
||||||
|
workspace_id = automation.workspace_id
|
||||||
await self.session.delete(automation)
|
await self.session.delete(automation)
|
||||||
await self.session.commit()
|
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:
|
async def _get_or_raise(self, automation_id: int) -> Automation:
|
||||||
automation = await self.session.get(Automation, automation_id)
|
automation = await self.session.get(Automation, automation_id)
|
||||||
if automation is None:
|
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 import get_trigger
|
||||||
from app.automations.triggers.builtin.schedule import compute_next_fire_at
|
from app.automations.triggers.builtin.schedule import compute_next_fire_at
|
||||||
from app.db import Permission, get_async_session
|
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.users import get_auth_context
|
||||||
from app.utils.rbac import check_permission
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
|
|
@ -48,6 +49,18 @@ class TriggerService:
|
||||||
self.session.add(trigger)
|
self.session.add(trigger)
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
await self.session.refresh(trigger)
|
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
|
return trigger
|
||||||
|
|
||||||
async def update(
|
async def update(
|
||||||
|
|
@ -82,6 +95,26 @@ class TriggerService:
|
||||||
|
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
await self.session.refresh(trigger)
|
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
|
return trigger
|
||||||
|
|
||||||
async def remove(self, *, automation_id: int, trigger_id: int) -> None:
|
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.delete(trigger)
|
||||||
await self.session.commit()
|
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(
|
async def _authorize_automation(
|
||||||
self, automation_id: int, permission: str
|
self, automation_id: int, permission: str
|
||||||
) -> Automation:
|
) -> 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
|
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.capabilities.core.types import Capability, CapabilityContext
|
||||||
from app.db import Run, async_session_maker, get_async_session
|
from app.db import Run, async_session_maker, get_async_session
|
||||||
from app.exceptions import ExternalServiceError, SurfSenseError
|
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.services.web_crawl_credit_service import InsufficientCreditsError
|
||||||
from app.users import get_auth_context
|
from app.users import get_auth_context
|
||||||
from app.utils.rbac import check_workspace_access
|
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"
|
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:
|
def _now_ms() -> int:
|
||||||
return int(time.time() * 1000)
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
@ -185,6 +221,8 @@ async def _execute_async_run(
|
||||||
unit,
|
unit,
|
||||||
executor,
|
executor,
|
||||||
payload,
|
payload,
|
||||||
|
user_id=None,
|
||||||
|
origin: str = "api",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run a scrape in the background: stream progress, charge, finalize the row.
|
"""Run a scrape in the background: stream progress, charge, finalize the row.
|
||||||
|
|
||||||
|
|
@ -218,6 +256,13 @@ async def _execute_async_run(
|
||||||
progress=reporter.coarse,
|
progress=reporter.coarse,
|
||||||
)
|
)
|
||||||
_publish_finished(run_id, "error", error=str(exc))
|
_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
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("async run %s failed with an upstream error", run_id)
|
logger.exception("async run %s failed with an upstream error", run_id)
|
||||||
|
|
@ -229,6 +274,13 @@ async def _execute_async_run(
|
||||||
progress=reporter.coarse,
|
progress=reporter.coarse,
|
||||||
)
|
)
|
||||||
_publish_finished(run_id, "error", error="upstream error")
|
_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
|
return
|
||||||
|
|
||||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||||
|
|
@ -252,6 +304,15 @@ async def _execute_async_run(
|
||||||
progress=reporter.coarse,
|
progress=reporter.coarse,
|
||||||
)
|
)
|
||||||
_publish_finished(run_id, "success", item_count=serialized.item_count)
|
_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(
|
async def _finalize_async(
|
||||||
|
|
@ -358,6 +419,8 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
||||||
unit=unit,
|
unit=unit,
|
||||||
executor=executor,
|
executor=executor,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
|
user_id=user_id,
|
||||||
|
origin=origin,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
run_event_bus.register_task(run_id, task)
|
run_event_bus.register_task(run_id, task)
|
||||||
|
|
@ -373,6 +436,7 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
||||||
try:
|
try:
|
||||||
output = await executor(payload)
|
output = await executor(payload)
|
||||||
except (SurfSenseError, HTTPException) as exc:
|
except (SurfSenseError, HTTPException) as exc:
|
||||||
|
_sync_err_duration = int((time.perf_counter() - started) * 1000)
|
||||||
await _record_rest_run(
|
await _record_rest_run(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
capability=name,
|
capability=name,
|
||||||
|
|
@ -381,11 +445,19 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
||||||
input=input_dump,
|
input=input_dump,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
duration_ms=_sync_err_duration,
|
||||||
progress=reporter.coarse,
|
progress=reporter.coarse,
|
||||||
)
|
)
|
||||||
|
_capture_scraper_run(
|
||||||
|
capability=name,
|
||||||
|
status="error",
|
||||||
|
user_id=user_id,
|
||||||
|
origin=origin,
|
||||||
|
duration_ms=_sync_err_duration,
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
_sync_err_duration = int((time.perf_counter() - started) * 1000)
|
||||||
await _record_rest_run(
|
await _record_rest_run(
|
||||||
workspace_id=workspace_id,
|
workspace_id=workspace_id,
|
||||||
capability=name,
|
capability=name,
|
||||||
|
|
@ -394,9 +466,16 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
||||||
input=input_dump,
|
input=input_dump,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
duration_ms=int((time.perf_counter() - started) * 1000),
|
duration_ms=_sync_err_duration,
|
||||||
progress=reporter.coarse,
|
progress=reporter.coarse,
|
||||||
)
|
)
|
||||||
|
_capture_scraper_run(
|
||||||
|
capability=name,
|
||||||
|
status="error",
|
||||||
|
user_id=user_id,
|
||||||
|
origin=origin,
|
||||||
|
duration_ms=_sync_err_duration,
|
||||||
|
)
|
||||||
raise ExternalServiceError(
|
raise ExternalServiceError(
|
||||||
f"The '{name}' capability failed due to an upstream error.",
|
f"The '{name}' capability failed due to an upstream error.",
|
||||||
code="CAPABILITY_UPSTREAM_ERROR",
|
code="CAPABILITY_UPSTREAM_ERROR",
|
||||||
|
|
@ -418,6 +497,15 @@ def _register_verb(router: APIRouter, capability: Capability) -> None:
|
||||||
cost_micros=cost_micros,
|
cost_micros=cost_micros,
|
||||||
progress=reporter.coarse,
|
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:
|
if run_id is not None:
|
||||||
response.headers["X-Run-Id"] = f"run_{run_id}"
|
response.headers["X-Run-Id"] = f"run_{run_id}"
|
||||||
return output
|
return output
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ from celery.signals import (
|
||||||
task_postrun,
|
task_postrun,
|
||||||
task_prerun,
|
task_prerun,
|
||||||
worker_process_init,
|
worker_process_init,
|
||||||
|
worker_process_shutdown,
|
||||||
)
|
)
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
@ -123,6 +124,18 @@ def init_worker(**kwargs):
|
||||||
initialize_image_gen_router()
|
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 configuration, sourced from the central Config singleton
|
||||||
CELERY_BROKER_URL = config.CELERY_BROKER_URL
|
CELERY_BROKER_URL = config.CELERY_BROKER_URL
|
||||||
CELERY_RESULT_BACKEND = config.CELERY_RESULT_BACKEND
|
CELERY_RESULT_BACKEND = config.CELERY_RESULT_BACKEND
|
||||||
|
|
|
||||||
|
|
@ -1179,6 +1179,19 @@ class Config:
|
||||||
os.getenv("CRAWL_HEADED_XVFB_ENABLED", "FALSE").upper() == "TRUE"
|
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
|
# Litellm TTS Configuration
|
||||||
TTS_SERVICE = os.getenv("TTS_SERVICE")
|
TTS_SERVICE = os.getenv("TTS_SERVICE")
|
||||||
TTS_SERVICE_API_BASE = os.getenv("TTS_SERVICE_API_BASE")
|
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.
|
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
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.celery_app import celery_app
|
from app.celery_app import celery_app
|
||||||
|
from app.observability import analytics as ph_analytics
|
||||||
from app.podcasts.persistence import PodcastRepository
|
from app.podcasts.persistence import PodcastRepository
|
||||||
from app.podcasts.rendering import PodcastRenderer
|
from app.podcasts.rendering import PodcastRenderer
|
||||||
from app.podcasts.service import (
|
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
|
podcast, storage_backend=backend_name, storage_key=key
|
||||||
)
|
)
|
||||||
await session.commit()
|
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:
|
except InvalidTransitionError:
|
||||||
# A user back-out won the race (e.g. the regeneration was
|
# A user back-out won the race (e.g. the regeneration was
|
||||||
# reverted): drop the stale render and leave the row alone.
|
# reverted): drop the stale render and leave the row alone.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -13,6 +14,10 @@ from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.config import config
|
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 (
|
from app.etl_pipeline.file_classifier import (
|
||||||
DIRECT_CONVERT_EXTENSIONS,
|
DIRECT_CONVERT_EXTENSIONS,
|
||||||
PLAINTEXT_EXTENSIONS,
|
PLAINTEXT_EXTENSIONS,
|
||||||
|
|
@ -354,6 +359,7 @@ async def stream_anonymous_chat(
|
||||||
|
|
||||||
accumulator = start_turn()
|
accumulator = start_turn()
|
||||||
streaming_service = VercelStreamingService()
|
streaming_service = VercelStreamingService()
|
||||||
|
anon_outcome = "success"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with shielded_async_session():
|
async with shielded_async_session():
|
||||||
|
|
@ -394,6 +400,21 @@ async def stream_anonymous_chat(
|
||||||
"recursion_limit": 40,
|
"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_message_start()
|
||||||
yield streaming_service.format_start_step()
|
yield streaming_service.format_start_step()
|
||||||
|
|
||||||
|
|
@ -465,6 +486,7 @@ async def stream_anonymous_chat(
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Anonymous chat stream error")
|
logger.exception("Anonymous chat stream error")
|
||||||
|
anon_outcome = "error"
|
||||||
await TokenQuotaService.anon_release(session_key, ip_key, request_id)
|
await TokenQuotaService.anon_release(session_key, ip_key, request_id)
|
||||||
_, error_code, _, _, user_message, extra = classify_stream_exception(
|
_, error_code, _, _, user_message, extra = classify_stream_exception(
|
||||||
e,
|
e,
|
||||||
|
|
@ -479,6 +501,26 @@ async def stream_anonymous_chat(
|
||||||
finally:
|
finally:
|
||||||
await TokenQuotaService.anon_release_stream_slot(client_ip)
|
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(
|
return StreamingResponse(
|
||||||
_generate(),
|
_generate(),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.auth.context import AuthContext
|
from app.auth.context import AuthContext
|
||||||
|
from app.observability import analytics as ph_analytics
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import (
|
from app.db import (
|
||||||
ImageGeneration,
|
ImageGeneration,
|
||||||
|
|
@ -327,6 +328,20 @@ async def create_image_generation(
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(db_image_gen)
|
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
|
return db_image_gen
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from app.db import (
|
||||||
UserIncentiveTask,
|
UserIncentiveTask,
|
||||||
get_async_session,
|
get_async_session,
|
||||||
)
|
)
|
||||||
|
from app.observability import analytics as ph_analytics
|
||||||
from app.schemas.incentive_tasks import (
|
from app.schemas.incentive_tasks import (
|
||||||
CompleteTaskResponse,
|
CompleteTaskResponse,
|
||||||
IncentiveTaskInfo,
|
IncentiveTaskInfo,
|
||||||
|
|
@ -125,6 +126,20 @@ async def complete_task(
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
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(
|
return CompleteTaskResponse(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"Task completed! You earned ${credit_micros_reward / 1_000_000:.2f} of credit.",
|
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,
|
get_async_session,
|
||||||
shielded_async_session,
|
shielded_async_session,
|
||||||
)
|
)
|
||||||
|
from app.observability import analytics as ph_analytics
|
||||||
from app.schemas.new_chat import (
|
from app.schemas.new_chat import (
|
||||||
AgentToolInfo,
|
AgentToolInfo,
|
||||||
CancelActiveTurnResponse,
|
CancelActiveTurnResponse,
|
||||||
|
|
@ -823,6 +824,15 @@ async def create_thread(
|
||||||
session.add(db_thread)
|
session.add(db_thread)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(db_thread)
|
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
|
return db_thread
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from sqlalchemy.future import select
|
||||||
from app.auth.context import AuthContext
|
from app.auth.context import AuthContext
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import PersonalAccessToken, get_async_session
|
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.schemas.pat import PATCreate, PATCreated, PATRead
|
||||||
from app.users import require_session_context
|
from app.users import require_session_context
|
||||||
from app.utils.pat import generate_pat, hash_pat, token_prefix
|
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.commit()
|
||||||
await session.refresh(pat)
|
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(
|
return PATCreated(
|
||||||
id=pat.id,
|
id=pat.id,
|
||||||
label=pat.label,
|
label=pat.label,
|
||||||
|
|
@ -102,3 +110,5 @@ async def delete_personal_access_token(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await session.commit()
|
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.auth.context import AuthContext
|
||||||
from app.db import get_async_session
|
from app.db import get_async_session
|
||||||
|
from app.observability import analytics as ph_analytics
|
||||||
from app.schemas.new_chat import (
|
from app.schemas.new_chat import (
|
||||||
CloneResponse,
|
CloneResponse,
|
||||||
PublicChatResponse,
|
PublicChatResponse,
|
||||||
|
|
@ -56,7 +57,20 @@ async def clone_public_chat(
|
||||||
Creates thread and copies messages.
|
Creates thread and copies messages.
|
||||||
Requires authentication.
|
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}")
|
@router.get("/{share_token}/podcasts/{podcast_id}")
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ from app.db import (
|
||||||
WorkspaceRole,
|
WorkspaceRole,
|
||||||
get_async_session,
|
get_async_session,
|
||||||
)
|
)
|
||||||
|
from app.observability import analytics as ph_analytics
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
InviteAcceptRequest,
|
InviteAcceptRequest,
|
||||||
InviteAcceptResponse,
|
InviteAcceptResponse,
|
||||||
|
|
@ -782,6 +783,19 @@ async def create_invite(
|
||||||
)
|
)
|
||||||
db_invite = result.scalars().first()
|
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
|
return db_invite
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -1091,6 +1105,16 @@ async def accept_invite(
|
||||||
role_name = invite.role.name if invite.role else "Default"
|
role_name = invite.role.name if invite.role else "Default"
|
||||||
workspace_name = invite.workspace.name if invite.workspace else ""
|
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(
|
return InviteAcceptResponse(
|
||||||
message="Successfully joined the workspace",
|
message="Successfully joined the workspace",
|
||||||
workspace_id=invite.workspace_id,
|
workspace_id=invite.workspace_id,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import redis
|
||||||
from dateutil.parser import isoparse
|
from dateutil.parser import isoparse
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
from sqlalchemy import event as sa_event
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
@ -44,6 +45,7 @@ from app.db import (
|
||||||
get_async_session,
|
get_async_session,
|
||||||
)
|
)
|
||||||
from app.notifications.service import NotificationService
|
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.observability import metrics as ot_metrics, otel as ot
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
GoogleDriveIndexRequest,
|
GoogleDriveIndexRequest,
|
||||||
|
|
@ -135,6 +137,45 @@ def _get_heartbeat_key(notification_id: int) -> str:
|
||||||
router = APIRouter()
|
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
|
# Use Pydantic's BaseModel here
|
||||||
class GitHubPATRequest(BaseModel):
|
class GitHubPATRequest(BaseModel):
|
||||||
github_pat: str = Field(..., description="GitHub Personal Access Token")
|
github_pat: str = Field(..., description="GitHub Personal Access Token")
|
||||||
|
|
@ -244,6 +285,10 @@ async def create_search_source_connector(
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(db_connector)
|
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
|
# Create periodic schedule if periodic indexing is enabled
|
||||||
if (
|
if (
|
||||||
db_connector.periodic_indexing_enabled
|
db_connector.periodic_indexing_enabled
|
||||||
|
|
@ -679,10 +724,23 @@ async def delete_search_source_connector(
|
||||||
|
|
||||||
# Delete the connector record
|
# Delete the connector record
|
||||||
workspace_id = db_connector.workspace_id
|
workspace_id = db_connector.workspace_id
|
||||||
|
deleted_connector_type = db_connector.connector_type
|
||||||
is_mcp = db_connector.connector_type == SearchSourceConnectorType.MCP_CONNECTOR
|
is_mcp = db_connector.connector_type == SearchSourceConnectorType.MCP_CONNECTOR
|
||||||
await session.delete(db_connector)
|
await session.delete(db_connector)
|
||||||
await session.commit()
|
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:
|
if is_mcp:
|
||||||
from app.agents.chat.multi_agent_chat.shared.tools.mcp.tool import (
|
from app.agents.chat.multi_agent_chat.shared.tools.mcp.tool import (
|
||||||
invalidate_mcp_tools_cache,
|
invalidate_mcp_tools_cache,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from app.db import (
|
||||||
User,
|
User,
|
||||||
get_async_session,
|
get_async_session,
|
||||||
)
|
)
|
||||||
|
from app.observability import analytics as ph_analytics
|
||||||
from app.schemas.stripe import (
|
from app.schemas.stripe import (
|
||||||
AutoReloadSettingsResponse,
|
AutoReloadSettingsResponse,
|
||||||
CreateAutoReloadSetupSessionRequest,
|
CreateAutoReloadSetupSessionRequest,
|
||||||
|
|
@ -47,6 +48,26 @@ logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/stripe", tags=["stripe"])
|
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:
|
def get_stripe_client() -> StripeClient:
|
||||||
"""Return a configured Stripe client or raise if Stripe is disabled."""
|
"""Return a configured Stripe client or raise if Stripe is disabled."""
|
||||||
if not config.STRIPE_SECRET_KEY:
|
if not config.STRIPE_SECRET_KEY:
|
||||||
|
|
@ -309,6 +330,7 @@ async def _fulfill_completed_credit_purchase(
|
||||||
)
|
)
|
||||||
|
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
_capture_credits_purchased(user, purchase)
|
||||||
return StripeWebhookResponse()
|
return StripeWebhookResponse()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -448,6 +470,8 @@ async def _reconcile_auto_reload_payment_intent(
|
||||||
purchase.status = CreditPurchaseStatus.FAILED
|
purchase.status = CreditPurchaseStatus.FAILED
|
||||||
|
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
if succeeded:
|
||||||
|
_capture_credits_purchased(user, purchase)
|
||||||
return StripeWebhookResponse()
|
return StripeWebhookResponse()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from app.db import (
|
||||||
get_async_session,
|
get_async_session,
|
||||||
get_default_roles_config,
|
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.routes.model_connections_routes import compute_llm_setup_status
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
WorkspaceApiAccessUpdate,
|
WorkspaceApiAccessUpdate,
|
||||||
|
|
@ -112,6 +113,16 @@ async def create_workspace(
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(db_workspace)
|
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 = WorkspaceRead.model_validate(db_workspace)
|
||||||
response.llm_setup = await compute_llm_setup_status(
|
response.llm_setup = await compute_llm_setup_status(
|
||||||
session, auth, db_workspace.id
|
session, auth, db_workspace.id
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Celery tasks for connector indexing."""
|
"""Celery tasks for connector indexing."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
|
|
@ -8,6 +9,7 @@ from collections.abc import Awaitable, Callable
|
||||||
from celery import current_task
|
from celery import current_task
|
||||||
|
|
||||||
from app.celery_app import celery_app
|
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.observability import metrics as ot_metrics, otel as ot
|
||||||
from app.tasks.celery_tasks import (
|
from app.tasks.celery_tasks import (
|
||||||
get_celery_session_maker,
|
get_celery_session_maker,
|
||||||
|
|
@ -17,8 +19,18 @@ from app.tasks.celery_tasks import (
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T:
|
def run_async_celery_task[T](
|
||||||
"""Run connector sync work and record aggregate connector metrics."""
|
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"
|
task_name = getattr(current_task, "name", None) or "unknown"
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
status = "failed"
|
status = "failed"
|
||||||
|
|
@ -45,6 +57,29 @@ def run_async_celery_task[T](coro_factory: Callable[[], Awaitable[T]]) -> T:
|
||||||
status=status,
|
status=status,
|
||||||
error_category=error_category,
|
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:
|
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(
|
return run_async_celery_task(
|
||||||
lambda: _index_notion_pages(
|
lambda: _index_notion_pages(
|
||||||
connector_id, workspace_id, user_id, start_date, end_date
|
connector_id, workspace_id, user_id, start_date, end_date
|
||||||
)
|
),
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_handle_greenlet_error(e, "index_notion_pages", connector_id)
|
_handle_greenlet_error(e, "index_notion_pages", connector_id)
|
||||||
|
|
@ -129,7 +166,9 @@ def index_github_repos_task(
|
||||||
return run_async_celery_task(
|
return run_async_celery_task(
|
||||||
lambda: _index_github_repos(
|
lambda: _index_github_repos(
|
||||||
connector_id, workspace_id, user_id, start_date, end_date
|
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(
|
return run_async_celery_task(
|
||||||
lambda: _index_confluence_pages(
|
lambda: _index_confluence_pages(
|
||||||
connector_id, workspace_id, user_id, start_date, end_date
|
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(
|
return run_async_celery_task(
|
||||||
lambda: _index_google_calendar_events(
|
lambda: _index_google_calendar_events(
|
||||||
connector_id, workspace_id, user_id, start_date, end_date
|
connector_id, workspace_id, user_id, start_date, end_date
|
||||||
)
|
),
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_handle_greenlet_error(e, "index_google_calendar_events", connector_id)
|
_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(
|
return run_async_celery_task(
|
||||||
lambda: _index_google_gmail_messages(
|
lambda: _index_google_gmail_messages(
|
||||||
connector_id, workspace_id, user_id, start_date, end_date
|
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,
|
workspace_id,
|
||||||
user_id,
|
user_id,
|
||||||
items_dict,
|
items_dict,
|
||||||
)
|
),
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -315,7 +362,9 @@ def index_onedrive_files_task(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
user_id,
|
user_id,
|
||||||
items_dict,
|
items_dict,
|
||||||
)
|
),
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -355,7 +404,9 @@ def index_dropbox_files_task(
|
||||||
workspace_id,
|
workspace_id,
|
||||||
user_id,
|
user_id,
|
||||||
items_dict,
|
items_dict,
|
||||||
)
|
),
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -393,7 +444,9 @@ def index_elasticsearch_documents_task(
|
||||||
return run_async_celery_task(
|
return run_async_celery_task(
|
||||||
lambda: _index_elasticsearch_documents(
|
lambda: _index_elasticsearch_documents(
|
||||||
connector_id, workspace_id, user_id, start_date, end_date
|
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(
|
return run_async_celery_task(
|
||||||
lambda: _index_bookstack_pages(
|
lambda: _index_bookstack_pages(
|
||||||
connector_id, workspace_id, user_id, start_date, end_date
|
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(
|
return run_async_celery_task(
|
||||||
lambda: _index_composio_connector(
|
lambda: _index_composio_connector(
|
||||||
connector_id, workspace_id, user_id, start_date, end_date
|
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 contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from app.celery_app import celery_app
|
from app.celery_app import celery_app
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.notifications.service import NotificationService
|
from app.notifications.service import NotificationService
|
||||||
|
from app.observability import analytics as ph_analytics
|
||||||
from app.observability import metrics as ot_metrics
|
from app.observability import metrics as ot_metrics
|
||||||
from app.services.task_logging_service import TaskLoggingService
|
from app.services.task_logging_service import TaskLoggingService
|
||||||
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
|
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__)
|
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 =====
|
# ===== Redis heartbeat for document processing tasks =====
|
||||||
# Same mechanism as connector indexing heartbeats (search_source_connectors_routes.py).
|
# 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.
|
# 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
|
workspace_id: ID of the workspace
|
||||||
user_id: ID of the user
|
user_id: ID of the user
|
||||||
"""
|
"""
|
||||||
return run_async_celery_task(
|
_t0 = time.perf_counter()
|
||||||
lambda: _process_extension_document(
|
try:
|
||||||
individual_document_dict, workspace_id, user_id
|
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(
|
async def _process_extension_document(
|
||||||
|
|
@ -430,12 +489,14 @@ def process_file_upload_task(
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
file_size: int | None = None
|
||||||
try:
|
try:
|
||||||
file_size = os.path.getsize(file_path)
|
file_size = os.path.getsize(file_path)
|
||||||
logger.info(f"[process_file_upload] File size: {file_size} bytes")
|
logger.info(f"[process_file_upload] File size: {file_size} bytes")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[process_file_upload] Could not get file size: {e}")
|
logger.warning(f"[process_file_upload] Could not get file size: {e}")
|
||||||
|
|
||||||
|
_t0 = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
run_async_celery_task(
|
run_async_celery_task(
|
||||||
lambda: _process_file_upload(file_path, filename, workspace_id, user_id)
|
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"[process_file_upload] Task failed for {filename}: {e}\n"
|
||||||
f"Traceback:\n{traceback.format_exc()}"
|
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
|
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(
|
async def _process_file_upload(
|
||||||
|
|
@ -682,6 +759,7 @@ def process_file_upload_with_document_task(
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
_t0 = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
run_async_celery_task(
|
run_async_celery_task(
|
||||||
lambda: _process_file_with_document(
|
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"[process_file_upload_with_document] Task failed for {filename}: {e}\n"
|
||||||
f"Traceback:\n{traceback.format_exc()}"
|
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
|
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):
|
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.celery_app import celery_app
|
||||||
from app.config import config as app_config
|
from app.config import config as app_config
|
||||||
from app.db import VideoPresentation, VideoPresentationStatus
|
from app.db import VideoPresentation, VideoPresentationStatus
|
||||||
|
from app.observability import analytics as ph_analytics
|
||||||
from app.services.billable_calls import (
|
from app.services.billable_calls import (
|
||||||
BillingSettlementError,
|
BillingSettlementError,
|
||||||
QuotaInsufficientError,
|
QuotaInsufficientError,
|
||||||
|
|
@ -239,6 +240,20 @@ async def _generate_video_presentation(
|
||||||
|
|
||||||
logger.info(f"Successfully generated video presentation: {video_pres.id}")
|
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 {
|
return {
|
||||||
"status": "ready",
|
"status": "ready",
|
||||||
"video_presentation_id": video_pres.id,
|
"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,
|
log_rate_limit_recovered,
|
||||||
reroute_to_next_auto_pin,
|
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 (
|
from app.tasks.chat.streaming.flows.shared.span import (
|
||||||
close_chat_request_span,
|
close_chat_request_span,
|
||||||
open_chat_request_span,
|
open_chat_request_span,
|
||||||
|
|
@ -487,6 +491,23 @@ async def stream_new_chat(
|
||||||
"recursion_limit": 10_000,
|
"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 ---
|
# --- Block 4: First SSE frames ---
|
||||||
|
|
||||||
for sse in iter_initial_frames(
|
for sse in iter_initial_frames(
|
||||||
|
|
@ -830,6 +851,27 @@ async def stream_new_chat(
|
||||||
log_prefix="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
|
# Persist any sandbox-produced files to local storage so they remain
|
||||||
# downloadable after the Daytona sandbox auto-deletes.
|
# downloadable after the Daytona sandbox auto-deletes.
|
||||||
if stream_result and stream_result.sandbox_files:
|
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,
|
log_rate_limit_recovered,
|
||||||
reroute_to_next_auto_pin,
|
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 (
|
from app.tasks.chat.streaming.flows.shared.span import (
|
||||||
close_chat_request_span,
|
close_chat_request_span,
|
||||||
open_chat_request_span,
|
open_chat_request_span,
|
||||||
|
|
@ -387,6 +391,22 @@ async def stream_resume_chat(
|
||||||
"recursion_limit": 10_000,
|
"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 ---
|
# --- First SSE frames ---
|
||||||
|
|
||||||
for sse in iter_initial_frames(
|
for sse in iter_initial_frames(
|
||||||
|
|
@ -599,6 +619,25 @@ async def stream_resume_chat(
|
||||||
log_prefix="stream_resume",
|
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
|
# Release the lock from the original interrupted turn or any
|
||||||
# re-interrupt/bailout. Skip on ``BusyError`` (lock not held here).
|
# re-interrupt/bailout. Skip on ``BusyError`` (lock not held here).
|
||||||
if not busy_error_raised:
|
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.context import AuthContext
|
||||||
from app.auth.session_cookies import access_expires_at, write_session
|
from app.auth.session_cookies import access_expires_at, write_session
|
||||||
from app.config import config
|
from app.config import config
|
||||||
|
from app.observability import analytics as ph_analytics
|
||||||
from app.db import (
|
from app.db import (
|
||||||
Prompt,
|
Prompt,
|
||||||
User,
|
User,
|
||||||
|
|
@ -144,6 +145,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to update last_login for user {user.id}: {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):
|
async def on_after_register(self, user: User, request: Request | None = None):
|
||||||
"""
|
"""
|
||||||
Called after a user registers. Creates a default workspace for the user
|
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(
|
logger.info(
|
||||||
f"Created default workspace (ID: {default_workspace.id}) for user {user.id}"
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create default workspace for user {user.id}: {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
|
FastAPI-Users still handles JWT mechanics; PATs are resolved here so RBAC
|
||||||
receives the full SurfSense principal instead of a bare User.
|
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")
|
auth_header = request.headers.get("Authorization")
|
||||||
if auth_header:
|
if auth_header:
|
||||||
scheme, _, credential = auth_header.partition(" ")
|
scheme, _, credential = auth_header.partition(" ")
|
||||||
|
|
@ -348,7 +370,7 @@ async def get_auth_context(
|
||||||
pat = await resolve_pat(session, token)
|
pat = await resolve_pat(session, token)
|
||||||
if pat and pat.user and pat.user.is_active:
|
if pat and pat.user and pat.user.is_active:
|
||||||
maybe_touch_last_used(pat)
|
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):
|
if is_bearer and _token_meets_epoch(token):
|
||||||
try:
|
try:
|
||||||
|
|
@ -358,7 +380,7 @@ async def get_auth_context(
|
||||||
user = None
|
user = None
|
||||||
|
|
||||||
if user and user.is_active:
|
if user and user.is_active:
|
||||||
return AuthContext.session(user)
|
return _stash(AuthContext.session(user))
|
||||||
|
|
||||||
cookie_token = request.cookies.get(config.SESSION_COOKIE_NAME)
|
cookie_token = request.cookies.get(config.SESSION_COOKIE_NAME)
|
||||||
if cookie_token and _token_meets_epoch(cookie_token):
|
if cookie_token and _token_meets_epoch(cookie_token):
|
||||||
|
|
@ -369,7 +391,7 @@ async def get_auth_context(
|
||||||
user = None
|
user = None
|
||||||
|
|
||||||
if user and user.is_active:
|
if user and user.is_active:
|
||||||
return AuthContext.session(user)
|
return _stash(AuthContext.session(user))
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ dependencies = [
|
||||||
"python-telegram-bot>=22.7",
|
"python-telegram-bot>=22.7",
|
||||||
"croniter>=2.0.0",
|
"croniter>=2.0.0",
|
||||||
"scrapling[fetchers]>=0.4.11",
|
"scrapling[fetchers]>=0.4.11",
|
||||||
|
"posthog>=6.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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'",
|
||||||
"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_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_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 = [[
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "banks"
|
name = "banks"
|
||||||
version = "2.4.1"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "preshed"
|
name = "preshed"
|
||||||
version = "3.0.13"
|
version = "3.0.13"
|
||||||
|
|
@ -10926,6 +11082,7 @@ dependencies = [
|
||||||
{ name = "opentelemetry-sdk" },
|
{ name = "opentelemetry-sdk" },
|
||||||
{ name = "opentelemetry-semantic-conventions" },
|
{ name = "opentelemetry-semantic-conventions" },
|
||||||
{ name = "pgvector" },
|
{ name = "pgvector" },
|
||||||
|
{ name = "posthog" },
|
||||||
{ name = "psycopg", extra = ["binary", "pool"] },
|
{ name = "psycopg", extra = ["binary", "pool"] },
|
||||||
{ name = "pyarrow" },
|
{ name = "pyarrow" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
|
|
@ -11041,6 +11198,7 @@ requires-dist = [
|
||||||
{ name = "opentelemetry-sdk", specifier = ">=1.40.0" },
|
{ name = "opentelemetry-sdk", specifier = ">=1.40.0" },
|
||||||
{ name = "opentelemetry-semantic-conventions", specifier = ">=0.61b0" },
|
{ name = "opentelemetry-semantic-conventions", specifier = ">=0.61b0" },
|
||||||
{ name = "pgvector", specifier = ">=0.3.6" },
|
{ name = "pgvector", specifier = ">=0.3.6" },
|
||||||
|
{ name = "posthog", specifier = ">=6.0.0" },
|
||||||
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" },
|
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.2" },
|
||||||
{ name = "pyarrow", specifier = ">=15.0.0,<19.0.0" },
|
{ name = "pyarrow", specifier = ">=15.0.0,<19.0.0" },
|
||||||
{ name = "pyjwt", specifier = ">=2.12.0" },
|
{ name = "pyjwt", specifier = ">=2.12.0" },
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,11 @@ class SurfSenseClient:
|
||||||
self._fallback_api_key = fallback_api_key
|
self._fallback_api_key = fallback_api_key
|
||||||
self._http = httpx.AsyncClient(
|
self._http = httpx.AsyncClient(
|
||||||
base_url=api_base,
|
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,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||||
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, isNetworkError } from "@/lib/auth-errors";
|
||||||
import { getPostLoginRedirectPath } from "@/lib/auth-utils";
|
import { getPostLoginRedirectPath } from "@/lib/auth-utils";
|
||||||
import { ValidationError } from "@/lib/error";
|
import { ValidationError } from "@/lib/error";
|
||||||
import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/posthog/events";
|
import { trackLoginAttempt, trackLoginFailure } from "@/lib/posthog/events";
|
||||||
|
|
||||||
export function LocalLoginForm() {
|
export function LocalLoginForm() {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
|
|
@ -45,8 +45,8 @@ export function LocalLoginForm() {
|
||||||
grant_type: "password",
|
grant_type: "password",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track successful login
|
// auth_login_success is now emitted server-side
|
||||||
trackLoginSuccess("local");
|
// (UserManager.on_after_login) — authoritative vs. optimistic.
|
||||||
|
|
||||||
// Small delay to show success message
|
// Small delay to show success message
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,7 @@ import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useSession } from "@/hooks/use-session";
|
import { useSession } from "@/hooks/use-session";
|
||||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||||
import { AppError, ValidationError } from "@/lib/error";
|
import { AppError, ValidationError } from "@/lib/error";
|
||||||
import {
|
import { trackRegistrationAttempt, trackRegistrationFailure } from "@/lib/posthog/events";
|
||||||
trackRegistrationAttempt,
|
|
||||||
trackRegistrationFailure,
|
|
||||||
trackRegistrationSuccess,
|
|
||||||
} from "@/lib/posthog/events";
|
|
||||||
import { AmbientBackground } from "../login/AmbientBackground";
|
import { AmbientBackground } from "../login/AmbientBackground";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
|
|
@ -81,8 +77,8 @@ export default function RegisterPage() {
|
||||||
is_verified: false,
|
is_verified: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track successful registration
|
// auth_registration_success is now emitted server-side
|
||||||
trackRegistrationSuccess();
|
// (UserManager.on_after_register) — authoritative vs. optimistic.
|
||||||
|
|
||||||
// Success toast
|
// Success toast
|
||||||
toast.success(t("register_success"), {
|
toast.success(t("register_success"), {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { useEffect } from "react";
|
||||||
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
|
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
|
||||||
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
|
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-query.atoms";
|
||||||
import { activeWorkspaceIdAtom } from "@/atoms/workspaces/workspace-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 { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import { LayoutDataProvider } from "@/components/layout";
|
import { LayoutDataProvider } from "@/components/layout";
|
||||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||||
|
|
@ -169,7 +168,6 @@ export function DashboardClientLayout({
|
||||||
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
|
initialPlaygroundSidebarCollapsed={initialPlaygroundSidebarCollapsed}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ConnectorIndicator showTrigger={false} />
|
|
||||||
</LayoutDataProvider>
|
</LayoutDataProvider>
|
||||||
</DocumentUploadDialogProvider>
|
</DocumentUploadDialogProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ export async function GET(
|
||||||
const response = new NextResponse(null, {
|
const response = new NextResponse(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
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, {
|
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 { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||||
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
||||||
import { formatRelativeDate } from "@/lib/format-date";
|
import { formatRelativeDate } from "@/lib/format-date";
|
||||||
import { trackWorkspaceInviteSent, trackWorkspaceUsersViewed } from "@/lib/posthog/events";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -226,12 +225,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
|
||||||
const canPrev = pageIndex > 0;
|
const canPrev = pageIndex > 0;
|
||||||
const canNext = displayEnd < totalItems;
|
const canNext = displayEnd < totalItems;
|
||||||
|
|
||||||
useEffect(() => {
|
// workspace_users_viewed removed — redundant with $pageview.
|
||||||
if (members.length > 0 && !membersLoading) {
|
|
||||||
const ownerCount = members.filter((m) => m.is_owner).length;
|
|
||||||
trackWorkspaceUsersViewed(workspaceId, members.length, ownerCount);
|
|
||||||
}
|
|
||||||
}, [members, membersLoading, workspaceId]);
|
|
||||||
|
|
||||||
if (accessLoading || membersLoading) {
|
if (accessLoading || membersLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -342,11 +336,7 @@ export function TeamContent({ workspaceId }: TeamContentProps) {
|
||||||
Invite members
|
Invite members
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<CreateInviteDialog
|
<CreateInviteDialog roles={roles} onCreateInvite={handleCreateInvite} />
|
||||||
roles={roles}
|
|
||||||
onCreateInvite={handleCreateInvite}
|
|
||||||
workspaceId={workspaceId}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{invitesLoading ? (
|
{invitesLoading ? (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -617,11 +607,9 @@ function MemberRow({
|
||||||
function CreateInviteDialog({
|
function CreateInviteDialog({
|
||||||
roles,
|
roles,
|
||||||
onCreateInvite,
|
onCreateInvite,
|
||||||
workspaceId,
|
|
||||||
}: {
|
}: {
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
|
onCreateInvite: (data: CreateInviteRequest["data"]) => Promise<Invite>;
|
||||||
workspaceId: number;
|
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
@ -653,12 +641,7 @@ function CreateInviteDialog({
|
||||||
const invite = await onCreateInvite(data);
|
const invite = await onCreateInvite(data);
|
||||||
setCreatedInvite(invite);
|
setCreatedInvite(invite);
|
||||||
|
|
||||||
const roleName = roleId ? roles.find((r) => r.id.toString() === roleId)?.name : undefined;
|
// workspace_invite_sent is now emitted server-side (rbac_routes.create_invite).
|
||||||
trackWorkspaceInviteSent(workspaceId, {
|
|
||||||
roleName,
|
|
||||||
hasExpiry: !!expiresAt,
|
|
||||||
hasMaxUses: !!maxUses,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create invite:", error);
|
console.error("Failed to create invite:", error);
|
||||||
toast.error("Failed to create invite. Please try again.");
|
toast.error("Failed to create invite. Please try again.");
|
||||||
|
|
|
||||||
|
|
@ -43,21 +43,15 @@ export default function DashboardError({
|
||||||
<Button type="button" onClick={reset}>
|
<Button type="button" onClick={reset}>
|
||||||
Try again
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Button asChild variant="ghost">
|
||||||
href="/dashboard"
|
<Link href="/dashboard">Back to dashboard</Link>
|
||||||
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"
|
</Button>
|
||||||
>
|
<Button asChild variant="ghost">
|
||||||
Go to dashboard home
|
<a href={issueUrl} target="_blank" rel="noopener noreferrer">
|
||||||
</Link>
|
Report Issue
|
||||||
<a
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
href={issueUrl}
|
</a>
|
||||||
target="_blank"
|
</Button>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { motion } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { use, useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms";
|
import { acceptInviteMutationAtom } from "@/atoms/invites/invites-mutation.atoms";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { useSession } from "@/hooks/use-session";
|
||||||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||||
import { setRedirectPath } from "@/lib/auth-utils";
|
import { setRedirectPath } from "@/lib/auth-utils";
|
||||||
import {
|
import { trackWorkspaceInviteDeclined } from "@/lib/posthog/events";
|
||||||
trackWorkspaceInviteAccepted,
|
|
||||||
trackWorkspaceInviteDeclined,
|
|
||||||
trackWorkspaceUserAdded,
|
|
||||||
} from "@/lib/posthog/events";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
export default function InviteAcceptPage() {
|
export default function InviteAcceptPage() {
|
||||||
|
|
@ -96,9 +92,9 @@ export default function InviteAcceptPage() {
|
||||||
setAccepted(true);
|
setAccepted(true);
|
||||||
setAcceptedData(result);
|
setAcceptedData(result);
|
||||||
|
|
||||||
// Track invite accepted and user added events
|
// workspace_invite_accepted + workspace_user_added are now emitted
|
||||||
trackWorkspaceInviteAccepted(result.workspace_id, result.workspace_name, result.role_name);
|
// server-side (rbac_routes.accept_invite) — the server redirect is
|
||||||
trackWorkspaceUserAdded(result.workspace_id, result.workspace_name, result.role_name);
|
// the authoritative join point.
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || "Failed to accept invite");
|
setError(err.message || "Failed to accept invite");
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,11 @@ import type {
|
||||||
} from "@/contracts/types/automation.types";
|
} from "@/contracts/types/automation.types";
|
||||||
import { automationsApiService } from "@/lib/apis/automations-api.service";
|
import { automationsApiService } from "@/lib/apis/automations-api.service";
|
||||||
import {
|
import {
|
||||||
trackAutomationCreated,
|
|
||||||
trackAutomationCreateFailed,
|
trackAutomationCreateFailed,
|
||||||
trackAutomationDeleted,
|
|
||||||
trackAutomationDeleteFailed,
|
trackAutomationDeleteFailed,
|
||||||
trackAutomationStatusChanged,
|
|
||||||
trackAutomationTriggerAdded,
|
|
||||||
trackAutomationTriggerAddFailed,
|
trackAutomationTriggerAddFailed,
|
||||||
trackAutomationTriggerRemoved,
|
|
||||||
trackAutomationTriggerRemoveFailed,
|
trackAutomationTriggerRemoveFailed,
|
||||||
trackAutomationTriggerUpdated,
|
|
||||||
trackAutomationTriggerUpdateFailed,
|
trackAutomationTriggerUpdateFailed,
|
||||||
trackAutomationUpdated,
|
|
||||||
trackAutomationUpdateFailed,
|
trackAutomationUpdateFailed,
|
||||||
} from "@/lib/posthog/events";
|
} from "@/lib/posthog/events";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
@ -48,20 +41,10 @@ export const createAutomationMutationAtom = atomWithMutation(() => ({
|
||||||
mutationFn: async (request: AutomationCreateRequest) => {
|
mutationFn: async (request: AutomationCreateRequest) => {
|
||||||
return automationsApiService.createAutomation(request);
|
return automationsApiService.createAutomation(request);
|
||||||
},
|
},
|
||||||
onSuccess: (automation, variables) => {
|
onSuccess: (_automation, variables) => {
|
||||||
invalidateList(variables.workspace_id);
|
invalidateList(variables.workspace_id);
|
||||||
toast.success("Automation created");
|
toast.success("Automation created");
|
||||||
trackAutomationCreated({
|
// automation_created is now emitted server-side (AutomationService.create).
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error: Error, variables) => {
|
onError: (error: Error, variables) => {
|
||||||
console.error("Error creating automation:", error);
|
console.error("Error creating automation:", error);
|
||||||
|
|
@ -82,24 +65,8 @@ export const updateAutomationMutationAtom = atomWithMutation(() => ({
|
||||||
invalidateDetail(vars.automationId);
|
invalidateDetail(vars.automationId);
|
||||||
invalidateList(automation.workspace_id);
|
invalidateList(automation.workspace_id);
|
||||||
toast.success("Automation updated");
|
toast.success("Automation updated");
|
||||||
// A status-only patch (pause/resume/archive) is a distinct action from a
|
// automation_updated / automation_status_changed are now emitted
|
||||||
// definition/name edit, so split it into its own event.
|
// server-side (AutomationService.update).
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: (error: Error, vars) => {
|
onError: (error: Error, vars) => {
|
||||||
console.error("Error updating automation:", error);
|
console.error("Error updating automation:", error);
|
||||||
|
|
@ -121,10 +88,7 @@ export const deleteAutomationMutationAtom = atomWithMutation(() => ({
|
||||||
invalidateList(vars.workspaceId);
|
invalidateList(vars.workspaceId);
|
||||||
invalidateDetail(vars.automationId);
|
invalidateDetail(vars.automationId);
|
||||||
toast.success("Automation deleted");
|
toast.success("Automation deleted");
|
||||||
trackAutomationDeleted({
|
// automation_deleted is now emitted server-side (AutomationService.delete).
|
||||||
automation_id: vars.automationId,
|
|
||||||
workspace_id: vars.workspaceId,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error: Error, vars) => {
|
onError: (error: Error, vars) => {
|
||||||
console.error("Error deleting automation:", error);
|
console.error("Error deleting automation:", error);
|
||||||
|
|
@ -141,16 +105,10 @@ export const addTriggerMutationAtom = atomWithMutation(() => ({
|
||||||
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
|
mutationFn: async (vars: { automationId: number; payload: TriggerCreateRequest }) => {
|
||||||
return automationsApiService.addTrigger(vars.automationId, vars.payload);
|
return automationsApiService.addTrigger(vars.automationId, vars.payload);
|
||||||
},
|
},
|
||||||
onSuccess: (trigger, vars) => {
|
onSuccess: (_trigger, vars) => {
|
||||||
invalidateDetail(vars.automationId);
|
invalidateDetail(vars.automationId);
|
||||||
toast.success("Trigger added");
|
toast.success("Trigger added");
|
||||||
trackAutomationTriggerAdded({
|
// automation_trigger_added is now emitted server-side (TriggerService.add).
|
||||||
automation_id: vars.automationId,
|
|
||||||
trigger_id: trigger.id,
|
|
||||||
trigger_type: trigger.type,
|
|
||||||
enabled: trigger.enabled,
|
|
||||||
has_cron: !!trigger.params?.cron,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error: Error, vars) => {
|
onError: (error: Error, vars) => {
|
||||||
console.error("Error adding trigger:", error);
|
console.error("Error adding trigger:", error);
|
||||||
|
|
@ -174,17 +132,7 @@ export const updateTriggerMutationAtom = atomWithMutation(() => ({
|
||||||
onSuccess: (_, vars) => {
|
onSuccess: (_, vars) => {
|
||||||
invalidateDetail(vars.automationId);
|
invalidateDetail(vars.automationId);
|
||||||
toast.success("Trigger updated");
|
toast.success("Trigger updated");
|
||||||
const change: "enabled" | "params" | "other" = vars.patch.params
|
// automation_trigger_updated is now emitted server-side (TriggerService.update).
|
||||||
? "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,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error: Error, vars) => {
|
onError: (error: Error, vars) => {
|
||||||
console.error("Error updating trigger:", error);
|
console.error("Error updating trigger:", error);
|
||||||
|
|
@ -206,10 +154,7 @@ export const removeTriggerMutationAtom = atomWithMutation(() => ({
|
||||||
onSuccess: (vars) => {
|
onSuccess: (vars) => {
|
||||||
invalidateDetail(vars.automationId);
|
invalidateDetail(vars.automationId);
|
||||||
toast.success("Trigger removed");
|
toast.success("Trigger removed");
|
||||||
trackAutomationTriggerRemoved({
|
// automation_trigger_removed is now emitted server-side (TriggerService.remove).
|
||||||
automation_id: vars.automationId,
|
|
||||||
trigger_id: vars.triggerId,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
onError: (error: Error, vars) => {
|
onError: (error: Error, vars) => {
|
||||||
console.error("Error removing trigger:", error);
|
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;
|
accountCount?: number;
|
||||||
connectorCount?: number;
|
connectorCount?: number;
|
||||||
isIndexing?: boolean;
|
isIndexing?: boolean;
|
||||||
deprecated?: boolean;
|
|
||||||
onConnect?: () => void;
|
onConnect?: () => void;
|
||||||
onManage?: () => void;
|
onManage?: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -53,15 +52,11 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
||||||
accountCount,
|
accountCount,
|
||||||
connectorCount,
|
connectorCount,
|
||||||
isIndexing = false,
|
isIndexing = false,
|
||||||
deprecated = false,
|
|
||||||
onConnect,
|
onConnect,
|
||||||
onManage,
|
onManage,
|
||||||
}) => {
|
}) => {
|
||||||
const isMCP = connectorType === EnumConnectorName.MCP_CONNECTOR;
|
const isMCP = connectorType === EnumConnectorName.MCP_CONNECTOR;
|
||||||
const isLive = !!connectorType && LIVE_CONNECTOR_TYPES.has(connectorType);
|
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
|
// Get connector status
|
||||||
const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } =
|
const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } =
|
||||||
useConnectorStatus();
|
useConnectorStatus();
|
||||||
|
|
@ -78,10 +73,6 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDeprecatedForConnect) {
|
|
||||||
return "Deprecated. No longer available to connect.";
|
|
||||||
}
|
|
||||||
|
|
||||||
return description;
|
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"
|
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isMCP ? (
|
{connectorType ? (
|
||||||
<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 ? (
|
|
||||||
getConnectorIcon(connectorType, "size-6")
|
getConnectorIcon(connectorType, "size-6")
|
||||||
) : id === "youtube-crawler" ? (
|
) : id === "youtube-crawler" ? (
|
||||||
<IconBrandYoutube className="size-6" />
|
<IconBrandYoutube className="size-6" />
|
||||||
|
|
@ -169,20 +151,18 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
||||||
!isConnected && "shadow-xs"
|
!isConnected && "shadow-xs"
|
||||||
)}
|
)}
|
||||||
onClick={isConnected ? onManage : onConnect}
|
onClick={isConnected ? onManage : onConnect}
|
||||||
disabled={isConnecting || !isEnabled || isDeprecatedForConnect}
|
disabled={isConnecting || !isEnabled}
|
||||||
>
|
>
|
||||||
<span className={isConnecting ? "opacity-0" : ""}>
|
<span className={isConnecting ? "opacity-0" : ""}>
|
||||||
{isDeprecatedForConnect
|
{!isEnabled
|
||||||
? "Deprecated"
|
? "Unavailable"
|
||||||
: !isEnabled
|
: isConnected
|
||||||
? "Unavailable"
|
? "Manage"
|
||||||
: isConnected
|
: id === "youtube-crawler"
|
||||||
? "Manage"
|
? "Add"
|
||||||
: id === "youtube-crawler"
|
: connectorType
|
||||||
? "Add"
|
? "Connect"
|
||||||
: connectorType
|
: "Add"}
|
||||||
? "Connect"
|
|
||||||
: "Add"}
|
|
||||||
</span>
|
</span>
|
||||||
{isConnecting && <Spinner size="xs" className="absolute" />}
|
{isConnecting && <Spinner size="xs" className="absolute" />}
|
||||||
</Button>
|
</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">
|
<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" />
|
<Server className="h-4 w-4 shrink-0" />
|
||||||
<AlertDescription className="text-[10px] sm:text-xs">
|
<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.
|
connector.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
@ -275,8 +275,8 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
||||||
<div className="mt-3 pt-3 border-t border-green-500/20">
|
<div className="mt-3 pt-3 border-t border-green-500/20">
|
||||||
<p className="font-semibold mb-2">Available tools:</p>
|
<p className="font-semibold mb-2">Available tools:</p>
|
||||||
<ul className="list-disc list-inside text-xs space-y-0.5">
|
<ul className="list-disc list-inside text-xs space-y-0.5">
|
||||||
{testResult.tools.map((tool, i) => (
|
{testResult.tools.map((tool) => (
|
||||||
<li key={i}>{tool.name}</li>
|
<li key={tool.name}>{tool.name}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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 })),
|
import("./components/obsidian-config").then((m) => ({ default: m.ObsidianConfig })),
|
||||||
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: () =>
|
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: () =>
|
||||||
import("./components/composio-drive-config").then((m) => ({ default: m.ComposioDriveConfig })),
|
import("./components/composio-drive-config").then((m) => ({ default: m.ComposioDriveConfig })),
|
||||||
COMPOSIO_GMAIL_CONNECTOR: () =>
|
// Composio Gmail/Calendar have nothing to configure; the edit view falls back
|
||||||
import("./components/composio-gmail-config").then((m) => ({ default: m.ComposioGmailConfig })),
|
// to LiveConnectorConnectedCard for live connectors without a config here.
|
||||||
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: () =>
|
|
||||||
import("./components/composio-calendar-config").then((m) => ({
|
|
||||||
default: m.ComposioCalendarConfig,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const componentCache = new Map<string, ConnectorConfigComponent>();
|
const componentCache = new Map<string, ConnectorConfigComponent>();
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
|
<div className="flex-shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -123,7 +123,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
||||||
{/* Form Content - Scrollable */}
|
{/* Form Content - Scrollable */}
|
||||||
<div
|
<div
|
||||||
ref={formContainerRef}
|
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
|
<ConnectFormComponent
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
|
@ -134,15 +134,15 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fixed Footer - Action buttons */}
|
{/* Fixed Footer - Action buttons */}
|
||||||
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-popover">
|
<div className="flex-shrink-0 flex items-center justify-end py-6 bg-transparent">
|
||||||
<Button
|
{/* <Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button> */}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleFormSubmit}
|
onClick={handleFormSubmit}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||||
import { VisionLLMConfig } from "../../components/vision-llm-config";
|
import { VisionLLMConfig } from "../../components/vision-llm-config";
|
||||||
import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
|
import { LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
|
||||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||||
|
import { LiveConnectorConnectedCard } from "../components/live-connector-connected-card";
|
||||||
import { MCPServiceConfig } from "../components/mcp-service-config";
|
import { MCPServiceConfig } from "../components/mcp-service-config";
|
||||||
import { getConnectorConfigComponent } from "../index";
|
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)
|
// Get connector-specific config component (MCP-backed connectors use a generic view)
|
||||||
const ConnectorConfigComponent = useMemo(() => {
|
const ConnectorConfigComponent = useMemo(() => {
|
||||||
if (isMCPBacked) return MCPServiceConfig;
|
if (isMCPBacked) return MCPServiceConfig;
|
||||||
return getConnectorConfigComponent(connector.connector_type);
|
// Live connectors without a dedicated config (native/Composio Gmail &
|
||||||
}, [connector.connector_type, isMCPBacked]);
|
// 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 [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [hasMoreContent, setHasMoreContent] = useState(false);
|
const [hasMoreContent, setHasMoreContent] = useState(false);
|
||||||
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
|
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
|
||||||
|
|
@ -201,7 +207,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
{/* Fixed Header */}
|
{/* Fixed Header */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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"
|
isScrolled && "shadow-sm"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -262,7 +268,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className="h-full overflow-y-auto px-6 sm:px-12"
|
className="h-full overflow-y-auto"
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<div className="space-y-6 pb-6 pt-2">
|
<div className="space-y-6 pb-6 pt-2">
|
||||||
|
|
@ -362,7 +368,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fixed Footer - Action buttons */}
|
{/* 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 ? (
|
{showDisconnectConfirm ? (
|
||||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 flex-1 sm:flex-initial">
|
<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">
|
<span className="text-xs sm:text-sm text-muted-foreground sm:whitespace-nowrap">
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
||||||
{/* Fixed Header */}
|
{/* Fixed Header */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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"
|
isScrolled && "shadow-sm"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -166,7 +166,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
||||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className="h-full overflow-y-auto px-6 sm:px-12"
|
className="h-full overflow-y-auto"
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<div className="space-y-6 pb-6 pt-2">
|
<div className="space-y-6 pb-6 pt-2">
|
||||||
|
|
@ -240,7 +240,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fixed Footer - Action buttons */}
|
{/* 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 ? (
|
{isLive ? (
|
||||||
<Button onClick={onSkip} className="text-xs sm:text-sm">
|
<Button onClick={onSkip} className="text-xs sm:text-sm">
|
||||||
Done
|
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 { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connectors retired during the MCP migration (no viable official MCP server).
|
* 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.
|
* UI behavior: a deprecated connector is hidden from the catalog entirely unless
|
||||||
* Reinstate by removing the type here and in the backend
|
* the user already connected it before deprecation — in that case it still shows
|
||||||
* `DEPRECATED_CONNECTOR_TYPES` if demand returns.
|
* 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>([
|
export const DEPRECATED_CONNECTOR_TYPES = new Set<string>([
|
||||||
EnumConnectorName.DISCORD_CONNECTOR,
|
EnumConnectorName.DISCORD_CONNECTOR,
|
||||||
EnumConnectorName.TEAMS_CONNECTOR,
|
EnumConnectorName.TEAMS_CONNECTOR,
|
||||||
EnumConnectorName.LUMA_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.TAVILY_API,
|
||||||
EnumConnectorName.SEARXNG_API,
|
EnumConnectorName.SEARXNG_API,
|
||||||
EnumConnectorName.LINKUP_API,
|
EnumConnectorName.LINKUP_API,
|
||||||
EnumConnectorName.BAIDU_SEARCH_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.YOUTUBE_CONNECTOR,
|
||||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||||
EnumConnectorName.ELASTICSEARCH_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).
|
* Connectors that operate in real time (no background indexing).
|
||||||
* Used to adjust UI: hide sync controls, show "Connected" instead of doc counts.
|
* 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.
|
* Collapse a flat connector list into one row per `connector_type`, titled for
|
||||||
* Drives the two top-level groupings in the connector catalog UI.
|
* 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 function groupConnectorsByType(connectors: SearchSourceConnector[]): ConnectorTypeRow[] {
|
||||||
|
const byType = new Map<string, SearchSourceConnector[]>();
|
||||||
export const CONNECTOR_CATEGORY_LABELS: Record<ConnectorCategory, string> = {
|
for (const c of connectors) {
|
||||||
knowledge_base: "Knowledge Base",
|
const arr = byType.get(c.connector_type) ?? [];
|
||||||
tools_live: "Tools & Live Sources",
|
arr.push(c);
|
||||||
};
|
byType.set(c.connector_type, arr);
|
||||||
|
}
|
||||||
const KNOWLEDGE_BASE_CONNECTOR_TYPES = new Set<string>([
|
return [...byType.entries()]
|
||||||
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
.map(([type, list]) => ({
|
||||||
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
type,
|
||||||
EnumConnectorName.ONEDRIVE_CONNECTOR,
|
title: type === EnumConnectorName.MCP_CONNECTOR ? "MCP Servers" : getConnectorTitle(type),
|
||||||
EnumConnectorName.DROPBOX_CONNECTOR,
|
connectors: list,
|
||||||
EnumConnectorName.NOTION_CONNECTOR,
|
accountCount: list.length,
|
||||||
EnumConnectorName.CONFLUENCE_CONNECTOR,
|
}))
|
||||||
EnumConnectorName.YOUTUBE_CONNECTOR,
|
.sort((a, b) => a.title.localeCompare(b.title));
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Composio Toolkits (available integrations via Composio)
|
// 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 { authenticatedFetch } from "@/lib/auth-fetch";
|
||||||
import { buildBackendUrl } from "@/lib/env-config";
|
import { buildBackendUrl } from "@/lib/env-config";
|
||||||
import {
|
import {
|
||||||
trackConnectorConnected,
|
|
||||||
trackConnectorDeleted,
|
|
||||||
trackConnectorSetupFailure,
|
trackConnectorSetupFailure,
|
||||||
trackConnectorSetupStarted,
|
trackConnectorSetupStarted,
|
||||||
trackIndexWithDateRangeOpened,
|
trackIndexWithDateRangeOpened,
|
||||||
|
|
@ -296,11 +294,9 @@ export const useConnectorDialog = () => {
|
||||||
if (newConnector && oauthConnector) {
|
if (newConnector && oauthConnector) {
|
||||||
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
||||||
if (connectorValidation.success) {
|
if (connectorValidation.success) {
|
||||||
trackConnectorConnected(
|
// connector_connected is now emitted server-side (after_insert
|
||||||
Number(workspaceId),
|
// listener in search_source_connectors_routes.py) — covers the
|
||||||
oauthConnector.connectorType,
|
// OAuth callback redirect the frontend often never observes.
|
||||||
newConnector.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType);
|
const isLiveConnector = LIVE_CONNECTOR_TYPES.has(oauthConnector.connectorType);
|
||||||
|
|
||||||
|
|
@ -436,12 +432,7 @@ export const useConnectorDialog = () => {
|
||||||
if (connector) {
|
if (connector) {
|
||||||
const connectorValidation = searchSourceConnector.safeParse(connector);
|
const connectorValidation = searchSourceConnector.safeParse(connector);
|
||||||
if (connectorValidation.success) {
|
if (connectorValidation.success) {
|
||||||
// Track webcrawler connector connected
|
// connector_connected is now emitted server-side (after_insert).
|
||||||
trackConnectorConnected(
|
|
||||||
Number(workspaceId),
|
|
||||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
|
||||||
connector.id
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = validateIndexingConfigState({
|
const config = validateIndexingConfigState({
|
||||||
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
connectorType: EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||||
|
|
@ -540,8 +531,7 @@ export const useConnectorDialog = () => {
|
||||||
// Store connectingConnectorType before clearing it
|
// Store connectingConnectorType before clearing it
|
||||||
const currentConnectorType = connectingConnectorType;
|
const currentConnectorType = connectingConnectorType;
|
||||||
|
|
||||||
// Track connector connected event for non-OAuth connectors
|
// connector_connected is now emitted server-side (after_insert).
|
||||||
trackConnectorConnected(Number(workspaceId), currentConnectorType, connector.id);
|
|
||||||
|
|
||||||
// Find connector title from constants
|
// Find connector title from constants
|
||||||
const connectorInfo = OTHER_CONNECTORS.find(
|
const connectorInfo = OTHER_CONNECTORS.find(
|
||||||
|
|
@ -1229,12 +1219,8 @@ export const useConnectorDialog = () => {
|
||||||
id: editingConnector.id,
|
id: editingConnector.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track connector deleted event
|
// connector_deleted is now emitted server-side
|
||||||
trackConnectorDeleted(
|
// (search_source_connectors_routes.delete_search_source_connector).
|
||||||
Number(workspaceId),
|
|
||||||
editingConnector.connector_type,
|
|
||||||
editingConnector.id
|
|
||||||
);
|
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
editingConnector.connector_type === "MCP_CONNECTOR"
|
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)
|
// Sub-components (if needed for external use)
|
||||||
export { ConnectorCard } from "./components/connector-card";
|
export { ConnectorCard } from "./components/connector-card";
|
||||||
export { ConnectorDialogHeader } from "./components/connector-dialog-header";
|
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 { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
COMPOSIO_CONNECTORS,
|
COMPOSIO_CONNECTORS,
|
||||||
IMPORT_CONNECTOR_TYPES,
|
|
||||||
LIVE_CONNECTOR_TYPES,
|
LIVE_CONNECTOR_TYPES,
|
||||||
OAUTH_CONNECTORS,
|
OAUTH_CONNECTORS,
|
||||||
} from "../constants/connector-constants";
|
} from "../constants/connector-constants";
|
||||||
|
|
@ -40,11 +39,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
onManage,
|
onManage,
|
||||||
onViewAccountsList,
|
onViewAccountsList,
|
||||||
}) => {
|
}) => {
|
||||||
// Import connectors (Drive/OneDrive/Dropbox) are managed via the Documents
|
const connectors = allActiveConnectors;
|
||||||
// sidebar "Import" menu, so they are excluded from the MCP connector list.
|
|
||||||
const connectors = allActiveConnectors.filter(
|
|
||||||
(c) => !IMPORT_CONNECTOR_TYPES.has(c.connector_type)
|
|
||||||
);
|
|
||||||
// Convert activeDocumentTypes array to Record for utility function
|
// Convert activeDocumentTypes array to Record for utility function
|
||||||
const documentTypeCounts = activeDocumentTypes.reduce(
|
const documentTypeCounts = activeDocumentTypes.reduce(
|
||||||
(acc, [docType, count]) => {
|
(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">
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
<Search className="size-8 text-muted-foreground mb-3" />
|
<Search className="size-8 text-muted-foreground mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">No connectors found</p>
|
<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>
|
</div>
|
||||||
) : hasSources ? (
|
) : hasSources ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Search } from "lucide-react";
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useIsSelfHosted } from "@/components/providers/runtime-config";
|
import { useIsSelfHosted } from "@/components/providers/runtime-config";
|
||||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
|
|
@ -9,12 +8,8 @@ import { usePlatform } from "@/hooks/use-platform";
|
||||||
import { ConnectorCard } from "../components/connector-card";
|
import { ConnectorCard } from "../components/connector-card";
|
||||||
import {
|
import {
|
||||||
COMPOSIO_CONNECTORS,
|
COMPOSIO_CONNECTORS,
|
||||||
CONNECTOR_CATEGORY_LABELS,
|
|
||||||
type ConnectorCategory,
|
|
||||||
CRAWLERS,
|
CRAWLERS,
|
||||||
DEPRECATED_CONNECTOR_TYPES,
|
DEPRECATED_CONNECTOR_TYPES,
|
||||||
getConnectorCategory,
|
|
||||||
IMPORT_CONNECTOR_TYPES,
|
|
||||||
OAUTH_CONNECTORS,
|
OAUTH_CONNECTORS,
|
||||||
OTHER_CONNECTORS,
|
OTHER_CONNECTORS,
|
||||||
} from "../constants/connector-constants";
|
} from "../constants/connector-constants";
|
||||||
|
|
@ -83,63 +78,39 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
const passesDeploymentFilter = (c: DeploymentFilterableConnector) =>
|
const passesDeploymentFilter = (c: DeploymentFilterableConnector) =>
|
||||||
(!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop);
|
(!c.selfHostedOnly || selfHosted) && (!c.desktopOnly || isDesktop);
|
||||||
|
|
||||||
// Import connectors (Drive/OneDrive/Dropbox) are managed via the Documents
|
// Filter connectors based on search and deployment mode
|
||||||
// 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
|
|
||||||
const filteredOAuth = OAUTH_CONNECTORS.filter(
|
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(
|
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(
|
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
|
// Filter Composio connectors
|
||||||
const filteredComposio = COMPOSIO_CONNECTORS.filter(
|
const filteredComposio = COMPOSIO_CONNECTORS.filter(
|
||||||
(c) =>
|
(c) =>
|
||||||
(c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
c.description.toLowerCase().includes(searchQuery.toLowerCase())) &&
|
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
notImport(c)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDeprecated = (c: { connectorType?: string }) =>
|
// One flat grid, no separate "Deprecated" section. A deprecated connector is
|
||||||
!!c.connectorType && DEPRECATED_CONNECTOR_TYPES.has(c.connectorType);
|
// shown only if the user already connected it (before deprecation), so it
|
||||||
|
// stays manageable/disconnectable; never-connected deprecated connectors are
|
||||||
const inCategory =
|
// hidden entirely. See DEPRECATED_CONNECTOR_TYPES for the full list.
|
||||||
(category: ConnectorCategory) =>
|
const isVisible = <T extends { connectorType?: string }>(c: T) =>
|
||||||
<T extends { connectorType?: string }>(connector: T): boolean =>
|
!c.connectorType ||
|
||||||
!isDeprecated(connector) &&
|
!DEPRECATED_CONNECTOR_TYPES.has(c.connectorType) ||
|
||||||
!!connector.connectorType &&
|
connectedTypes.has(c.connectorType);
|
||||||
getConnectorCategory(connector.connectorType) === category;
|
const available = {
|
||||||
|
oauth: filteredOAuth.filter(isVisible),
|
||||||
const knowledgeBase = {
|
composio: filteredComposio.filter(isVisible),
|
||||||
oauth: filteredOAuth.filter(inCategory("knowledge_base")),
|
other: filteredOther.filter(isVisible),
|
||||||
composio: filteredComposio.filter(inCategory("knowledge_base")),
|
crawlers: filteredCrawlers.filter(isVisible),
|
||||||
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),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderOAuthCard = (connector: OAuthConnector | ComposioConnector) => {
|
const renderOAuthCard = (connector: OAuthConnector | ComposioConnector) => {
|
||||||
|
|
@ -169,7 +140,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
documentCount={documentCount}
|
documentCount={documentCount}
|
||||||
accountCount={accountCount}
|
accountCount={accountCount}
|
||||||
isIndexing={isIndexing}
|
isIndexing={isIndexing}
|
||||||
deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
|
|
||||||
onConnect={() => onConnectOAuth(connector)}
|
onConnect={() => onConnectOAuth(connector)}
|
||||||
onManage={
|
onManage={
|
||||||
isConnected && onViewAccountsList
|
isConnected && onViewAccountsList
|
||||||
|
|
@ -218,7 +188,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
documentCount={documentCount}
|
documentCount={documentCount}
|
||||||
connectorCount={mcpConnectorCount}
|
connectorCount={mcpConnectorCount}
|
||||||
isIndexing={isIndexing}
|
isIndexing={isIndexing}
|
||||||
deprecated={DEPRECATED_CONNECTOR_TYPES.has(connector.connectorType)}
|
|
||||||
onConnect={handleConnect}
|
onConnect={handleConnect}
|
||||||
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
|
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
@ -267,86 +236,34 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
isConnecting={isConnecting}
|
isConnecting={isConnecting}
|
||||||
documentCount={documentCount}
|
documentCount={documentCount}
|
||||||
isIndexing={isIndexing}
|
isIndexing={isIndexing}
|
||||||
deprecated={
|
|
||||||
!!crawler.connectorType && DEPRECATED_CONNECTOR_TYPES.has(crawler.connectorType)
|
|
||||||
}
|
|
||||||
onConnect={handleConnect}
|
onConnect={handleConnect}
|
||||||
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
|
onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasKnowledgeBase =
|
const hasAnyResults =
|
||||||
knowledgeBase.oauth.length > 0 ||
|
available.oauth.length > 0 ||
|
||||||
knowledgeBase.composio.length > 0 ||
|
available.composio.length > 0 ||
|
||||||
knowledgeBase.other.length > 0 ||
|
available.other.length > 0 ||
|
||||||
knowledgeBase.crawlers.length > 0;
|
available.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;
|
|
||||||
|
|
||||||
if (!hasAnyResults && searchQuery) {
|
if (!hasAnyResults && searchQuery) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
<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-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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{hasKnowledgeBase && (
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<section>
|
{available.oauth.map(renderOAuthCard)}
|
||||||
<div className="flex items-center gap-2 mb-4">
|
{available.composio.map(renderOAuthCard)}
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
{available.crawlers.map(renderCrawlerCard)}
|
||||||
{CONNECTOR_CATEGORY_LABELS.knowledge_base}
|
{available.other.map(renderOtherCard)}
|
||||||
</h3>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* 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 */}
|
{/* Back button */}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -165,7 +165,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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 */}
|
{/* Connected Accounts Grid */}
|
||||||
{typeConnectors.length === 0 ? (
|
{typeConnectors.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="shrink-0 px-6 sm:px-12 pt-8 sm:pt-10">
|
<div className="shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -239,7 +239,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Form Content - Scrollable */}
|
{/* 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-4 pb-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="video-input" className="text-sm sm:text-base">
|
<Label htmlFor="video-input" className="text-sm sm:text-base">
|
||||||
|
|
@ -325,7 +325,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ workspaceId, o
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fixed Footer - Action buttons */}
|
{/* 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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,17 @@ import {
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
Camera,
|
Camera,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
|
||||||
Clipboard,
|
Clipboard,
|
||||||
|
LayoutGrid,
|
||||||
Plus,
|
Plus,
|
||||||
Settings2,
|
Settings2,
|
||||||
SquareIcon,
|
SquareIcon,
|
||||||
|
TriangleAlert,
|
||||||
Unplug,
|
Unplug,
|
||||||
Upload,
|
Upload,
|
||||||
Wrench,
|
Wrench,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
@ -44,7 +44,7 @@ import {
|
||||||
clearPremiumAlertForThreadAtom,
|
clearPremiumAlertForThreadAtom,
|
||||||
premiumAlertByThreadAtom,
|
premiumAlertByThreadAtom,
|
||||||
} from "@/atoms/chat/premium-alert.atom";
|
} 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 { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { llmSetupStatusAtomFamily } from "@/atoms/model-connections/model-connections-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 { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||||
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||||
import { ChatViewport } from "@/components/assistant-ui/chat-viewport";
|
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 { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import {
|
import {
|
||||||
InlineMentionEditor,
|
InlineMentionEditor,
|
||||||
|
|
@ -68,19 +73,12 @@ import { ComposerSuggestionPopoverContent } from "@/components/new-chat/composer
|
||||||
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
|
import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker";
|
||||||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuPortal,
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
|
|
@ -88,6 +86,7 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Popover, PopoverAnchor } from "@/components/ui/popover";
|
import { Popover, PopoverAnchor } from "@/components/ui/popover";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
|
|
@ -97,6 +96,7 @@ import {
|
||||||
getToolDisplayName,
|
getToolDisplayName,
|
||||||
getToolIcon,
|
getToolIcon,
|
||||||
} from "@/contracts/enums/toolIcons";
|
} from "@/contracts/enums/toolIcons";
|
||||||
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||||
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
|
@ -173,7 +173,7 @@ const ThreadContent: FC<ThreadProps> = ({
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<PremiumQuotaPinnedAlert />
|
<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 PendingScreenImageStrip: FC = () => {
|
||||||
const [urls, setUrls] = useAtom(pendingUserImageDataUrlsAtom);
|
const [urls, setUrls] = useAtom(pendingUserImageDataUrlsAtom);
|
||||||
if (urls.length === 0) return null;
|
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
|
* 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).
|
|
||||||
*
|
*
|
||||||
* Replaces the old full-screen onboarding redirect: a workspace with no usable
|
* 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
|
* 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 {
|
interface ComposerProps {
|
||||||
hasActiveThread?: boolean;
|
|
||||||
isLoadingMessages?: boolean;
|
isLoadingMessages?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Composer: FC<ComposerProps> = ({
|
const Composer: FC<ComposerProps> = ({ isLoadingMessages = false }) => {
|
||||||
hasActiveThread = false,
|
|
||||||
isLoadingMessages = false,
|
|
||||||
}) => {
|
|
||||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||||
const setSubmittedMentions = useSetAtom(submittedMentionsAtom);
|
const setSubmittedMentions = useSetAtom(submittedMentionsAtom);
|
||||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
||||||
|
|
@ -575,7 +477,6 @@ const Composer: FC<ComposerProps> = ({
|
||||||
|
|
||||||
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
|
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
|
||||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||||
const [connectToolsTrayVisible, setConnectToolsTrayVisible] = useState(false);
|
|
||||||
|
|
||||||
const { data: chatSetupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId ?? 0));
|
const { data: chatSetupStatus } = useAtomValue(llmSetupStatusAtomFamily(workspaceId ?? 0));
|
||||||
const isChatUnavailable = !!chatSetupStatus && chatSetupStatus.status !== "ready";
|
const isChatUnavailable = !!chatSetupStatus && chatSetupStatus.status !== "ready";
|
||||||
|
|
@ -1037,7 +938,6 @@ const Composer: FC<ComposerProps> = ({
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"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"
|
isChatUnavailable && "shadow-none dark:shadow-none"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -1071,10 +971,6 @@ const Composer: FC<ComposerProps> = ({
|
||||||
onChatModelSelected={handleChatModelSelected}
|
onChatModelSelected={handleChatModelSelected}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ConnectToolsBanner
|
|
||||||
isThreadEmpty={!hasActiveThread && isThreadEmpty}
|
|
||||||
onVisibleChange={setConnectToolsTrayVisible}
|
|
||||||
/>
|
|
||||||
{!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
|
{!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
|
||||||
<div className="absolute top-full left-0 right-0 z-20">
|
<div className="absolute top-full left-0 right-0 z-20">
|
||||||
<ChatExamplePrompts onSelect={handleExampleSelect} />
|
<ChatExamplePrompts onSelect={handleExampleSelect} />
|
||||||
|
|
@ -1152,12 +1048,13 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
||||||
onChatModelSelected,
|
onChatModelSelected,
|
||||||
}) => {
|
}) => {
|
||||||
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
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 [toolsPopoverOpen, setToolsPopoverOpen] = useState(false);
|
||||||
const [openConnectorSubmenu, setOpenConnectorSubmenu] = useState<string | null>(null);
|
const [openConnectorSubmenu, setOpenConnectorSubmenu] = useState<string | null>(null);
|
||||||
const [expandedConnectorGroups, setExpandedConnectorGroups] = useState<Set<string>>(
|
|
||||||
() => new Set()
|
|
||||||
);
|
|
||||||
const isDesktop = useMediaQuery("(min-width: 640px)");
|
const isDesktop = useMediaQuery("(min-width: 640px)");
|
||||||
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
|
||||||
const pendingScreenImages = useAtomValue(pendingUserImageDataUrlsAtom);
|
const pendingScreenImages = useAtomValue(pendingUserImageDataUrlsAtom);
|
||||||
|
|
@ -1192,6 +1089,20 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
||||||
() => new Set<string>((connectors ?? []).map((c) => c.connector_type)),
|
() => new Set<string>((connectors ?? []).map((c) => c.connector_type)),
|
||||||
[connectors]
|
[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(
|
const toggleToolGroup = useCallback(
|
||||||
(toolNames: string[]) => {
|
(toolNames: string[]) => {
|
||||||
|
|
@ -1204,18 +1115,6 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
||||||
},
|
},
|
||||||
[disabledToolsSet, setDisabledTools]
|
[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 filteredTools = agentTools;
|
||||||
const groupedTools = useMemo(() => {
|
const groupedTools = useMemo(() => {
|
||||||
if (!filteredTools) return [];
|
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="aui-composer-action-wrapper relative mx-3 mb-3 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{!isDesktop ? (
|
{!isDesktop ? (
|
||||||
<>
|
<ComposerAddMenuDrawer
|
||||||
<DropdownMenu>
|
trigger={
|
||||||
<DropdownMenuTrigger asChild>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
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"
|
||||||
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"
|
||||||
aria-label="Upload files, manage tools and more"
|
data-joyride="connector-icon"
|
||||||
data-joyride="connector-icon"
|
>
|
||||||
>
|
<Plus className="size-5" />
|
||||||
<Plus className="size-5" />
|
</Button>
|
||||||
</Button>
|
}
|
||||||
</DropdownMenuTrigger>
|
onUploadFiles={() => openUploadDialog()}
|
||||||
<DropdownMenuContent side="bottom" align="start" sideOffset={8}>
|
connectorRows={connectorRows}
|
||||||
<DropdownMenuItem onSelect={() => openUploadDialog()}>
|
onSelectConnector={openConnectorManage}
|
||||||
<Upload className="size-4" />
|
onBrowseConnectors={openConnectors}
|
||||||
Upload Files
|
regularToolGroups={regularToolGroups}
|
||||||
</DropdownMenuItem>
|
connectorToolGroups={connectorToolGroups}
|
||||||
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
|
otherToolGroup={otherToolGroup}
|
||||||
<Unplug className="size-4" />
|
disabledToolsSet={disabledToolsSet}
|
||||||
MCP Connectors
|
onToggleTool={toggleTool}
|
||||||
</DropdownMenuItem>
|
onToggleToolGroup={toggleToolGroup}
|
||||||
<DropdownMenuItem onSelect={() => setToolsPopoverOpen(true)}>
|
toolsLoading={!filteredTools?.length}
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
@ -1522,10 +1245,53 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
||||||
<Camera className="h-4 w-4" />
|
<Camera className="h-4 w-4" />
|
||||||
Take a screenshot
|
Take a screenshot
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => setConnectorDialogOpen(true)}>
|
{connectorRows.length > 0 ? (
|
||||||
<Unplug className="h-4 w-4" />
|
<DropdownMenuSub>
|
||||||
MCP Connectors
|
<DropdownMenuSubTrigger>
|
||||||
</DropdownMenuItem>
|
<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
|
<DropdownMenuSub
|
||||||
open={toolsPopoverOpen}
|
open={toolsPopoverOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|
@ -1546,7 +1312,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
||||||
>
|
>
|
||||||
{regularToolGroups.map((group) => (
|
{regularToolGroups.map((group) => (
|
||||||
<div key={group.label}>
|
<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}
|
{group.label}
|
||||||
</div>
|
</div>
|
||||||
{group.tools.map((tool) => {
|
{group.tools.map((tool) => {
|
||||||
|
|
@ -1581,7 +1347,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
||||||
))}
|
))}
|
||||||
{connectorToolGroups.length > 0 && (
|
{connectorToolGroups.length > 0 && (
|
||||||
<div>
|
<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
|
Connector Actions
|
||||||
</div>
|
</div>
|
||||||
{connectorToolGroups.map((group) => {
|
{connectorToolGroups.map((group) => {
|
||||||
|
|
@ -1667,7 +1433,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
|
||||||
)}
|
)}
|
||||||
{otherToolGroup && (
|
{otherToolGroup && (
|
||||||
<div>
|
<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}
|
{otherToolGroup.label}
|
||||||
</div>
|
</div>
|
||||||
{otherToolGroup.tools.map((tool) => {
|
{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">
|
<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" />
|
<Search className="h-10 w-10" />
|
||||||
<p className="text-sm text-muted-foreground">No matching documents</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
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 { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
|
@ -300,6 +300,7 @@ export function LayoutDataProvider({
|
||||||
const isAutomationsActive = pathname?.includes("/automations") === true;
|
const isAutomationsActive = pathname?.includes("/automations") === true;
|
||||||
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
|
const isArtifactsActive = pathname?.endsWith("/artifacts") === true;
|
||||||
const isPlaygroundRoute = pathname?.includes("/playground") === true;
|
const isPlaygroundRoute = pathname?.includes("/playground") === true;
|
||||||
|
const isConnectorsRoute = pathname?.includes("/connectors") === true;
|
||||||
const navItems: NavItem[] = useMemo(
|
const navItems: NavItem[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(
|
(
|
||||||
|
|
@ -316,6 +317,12 @@ export function LayoutDataProvider({
|
||||||
icon: Shapes,
|
icon: Shapes,
|
||||||
isActive: isArtifactsActive,
|
isActive: isArtifactsActive,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Connectors",
|
||||||
|
url: `/dashboard/${workspaceId}/connectors`,
|
||||||
|
icon: Unplug,
|
||||||
|
isActive: isConnectorsRoute,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Playground",
|
title: "Playground",
|
||||||
url: `/dashboard/${workspaceId}/playground`,
|
url: `/dashboard/${workspaceId}/playground`,
|
||||||
|
|
@ -324,7 +331,7 @@ export function LayoutDataProvider({
|
||||||
},
|
},
|
||||||
] as (NavItem | null)[]
|
] as (NavItem | null)[]
|
||||||
).filter((item): item is NavItem => item !== null),
|
).filter((item): item is NavItem => item !== null),
|
||||||
[workspaceId, isAutomationsActive, isArtifactsActive, isPlaygroundRoute]
|
[workspaceId, isAutomationsActive, isArtifactsActive, isConnectorsRoute, isPlaygroundRoute]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
|
|
@ -631,6 +638,7 @@ export function LayoutDataProvider({
|
||||||
const isAutomationsPage = pathname?.includes("/automations") === true;
|
const isAutomationsPage = pathname?.includes("/automations") === true;
|
||||||
const isArtifactsPage = pathname?.endsWith("/artifacts") === true;
|
const isArtifactsPage = pathname?.endsWith("/artifacts") === true;
|
||||||
const isPlaygroundPage = pathname?.includes("/playground") === true;
|
const isPlaygroundPage = pathname?.includes("/playground") === true;
|
||||||
|
const isConnectorsPage = pathname?.includes("/connectors") === true;
|
||||||
const isAllChatsPage = pathname?.endsWith("/chats") === true;
|
const isAllChatsPage = pathname?.endsWith("/chats") === true;
|
||||||
const handleChatsClick = useCallback(() => {
|
const handleChatsClick = useCallback(() => {
|
||||||
router.push(`/dashboard/${workspaceId}/chats`);
|
router.push(`/dashboard/${workspaceId}/chats`);
|
||||||
|
|
@ -650,6 +658,7 @@ export function LayoutDataProvider({
|
||||||
isAutomationsPage ||
|
isAutomationsPage ||
|
||||||
isArtifactsPage ||
|
isArtifactsPage ||
|
||||||
isPlaygroundPage ||
|
isPlaygroundPage ||
|
||||||
|
isConnectorsPage ||
|
||||||
isAllChatsPage;
|
isAllChatsPage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -702,6 +711,7 @@ export function LayoutDataProvider({
|
||||||
isAutomationsPage ||
|
isAutomationsPage ||
|
||||||
isArtifactsPage ||
|
isArtifactsPage ||
|
||||||
isPlaygroundPage ||
|
isPlaygroundPage ||
|
||||||
|
isConnectorsPage ||
|
||||||
isAllChatsPage
|
isAllChatsPage
|
||||||
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
|
? "items-start justify-center px-6 py-8 md:px-10 md:pb-10 md:pt-16"
|
||||||
: undefined
|
: undefined
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import {
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { trackWorkspaceCreated } from "@/lib/posthog/events";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { queryClient } from "@/lib/query-client/client";
|
import { queryClient } from "@/lib/query-client/client";
|
||||||
|
|
||||||
|
|
@ -68,7 +67,8 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia
|
||||||
description: values.description || "",
|
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
|
// Seed the gate's query so it resolves without a loader flash, and
|
||||||
// route straight to onboarding vs. new-chat on the first hop.
|
// route straight to onboarding vs. new-chat on the first hop.
|
||||||
|
|
|
||||||
|
|
@ -487,13 +487,9 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
|
||||||
</div>
|
</div>
|
||||||
) : isSearchMode ? (
|
) : isSearchMode ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Search className="mx-auto mb-2.5 h-10 w-10 text-muted-foreground" />
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("no_chats_found") || "No chats found"}
|
{t("no_chats_found") || "No chats found"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-[11px] text-muted-foreground/70">
|
|
||||||
{t("try_different_search") || "Try a different search term"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,18 @@ import {
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
FolderSync,
|
FolderSync,
|
||||||
ListFilter,
|
ListFilter,
|
||||||
Plus,
|
|
||||||
Settings2,
|
|
||||||
SlidersVertical,
|
SlidersVertical,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
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 { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||||
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
|
import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms";
|
||||||
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms";
|
||||||
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
|
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 { CreateFolderDialog } from "@/components/documents/CreateFolderDialog";
|
||||||
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
|
||||||
import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon";
|
import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon";
|
||||||
|
|
@ -34,7 +27,7 @@ import type { FolderDisplay } from "@/components/documents/FolderNode";
|
||||||
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
|
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
|
||||||
import { FolderTreeView } from "@/components/documents/FolderTreeView";
|
import { FolderTreeView } from "@/components/documents/FolderTreeView";
|
||||||
import { VersionHistoryDialog } from "@/components/documents/version-history";
|
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 { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
|
||||||
import {
|
import {
|
||||||
DEFAULT_EXCLUDE_PATTERNS,
|
DEFAULT_EXCLUDE_PATTERNS,
|
||||||
|
|
@ -57,7 +50,6 @@ import {
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
|
|
@ -67,9 +59,6 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
|
import { useAnonymousMode, useIsAnonymous } from "@/contexts/anonymous-mode";
|
||||||
import { useLoginGate } from "@/contexts/login-gate";
|
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 type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
|
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
|
* Desktop-only "Watch Local Folder" entry point. File uploads now live in the
|
||||||
* (Google Drive / OneDrive / Dropbox). Drive/OneDrive/Dropbox set
|
* chat composer and cloud drives in the connector catalog, so this renders
|
||||||
* `importConnectorRequestAtom`, which the connector dialog consumes to run
|
* nothing on web. `gate` login-gates the action in anonymous mode.
|
||||||
* OAuth or open the existing account's config. In anonymous mode, `gate`
|
|
||||||
* intercepts every item to trigger the login flow.
|
|
||||||
*/
|
*/
|
||||||
export function EmbeddedImportMenu({
|
export function EmbeddedImportMenu({
|
||||||
gate,
|
gate,
|
||||||
|
|
@ -252,30 +239,14 @@ export function EmbeddedImportMenu({
|
||||||
gate?: (feature: string) => void;
|
gate?: (feature: string) => void;
|
||||||
onFolderWatched?: () => 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).
|
// Watch Local Folder is a desktop-app feature (needs the Electron folder watcher).
|
||||||
const { isDesktop } = usePlatform();
|
const { isDesktop } = usePlatform();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
|
const workspaceId = getWorkspaceIdNumber(params) ?? 0;
|
||||||
const [folderWatchOpen, setFolderWatchOpen] = useState(false);
|
const [folderWatchOpen, setFolderWatchOpen] = useState(false);
|
||||||
|
|
||||||
// Native Google Drive connector self-hosted only; hosted deployments use Composio.
|
// Nothing to import on web anymore — hide the button rather than show an empty menu.
|
||||||
const driveType = selfHosted
|
if (!isDesktop) return null;
|
||||||
? 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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -285,99 +256,20 @@ export function EmbeddedImportMenu({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
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" />
|
<FilePlus className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
<DropdownMenuItem onSelect={() => (gate ? gate("upload files") : openDialog())}>
|
<DropdownMenuItem
|
||||||
<Upload className="h-4 w-4" />
|
onSelect={() => (gate ? gate("watch local folders") : setFolderWatchOpen(true))}
|
||||||
Upload Files
|
>
|
||||||
|
<FolderSync className="h-4 w-4" />
|
||||||
|
Watch Local Folder
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
{isDesktop && !gate && (
|
{!gate && (
|
||||||
<FolderWatchDialog
|
<FolderWatchDialog
|
||||||
open={folderWatchOpen}
|
open={folderWatchOpen}
|
||||||
onOpenChange={setFolderWatchOpen}
|
onOpenChange={setFolderWatchOpen}
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,10 @@ export function Sidebar({
|
||||||
() => navItems.find((item) => item.url.endsWith("/artifacts")),
|
() => navItems.find((item) => item.url.endsWith("/artifacts")),
|
||||||
[navItems]
|
[navItems]
|
||||||
);
|
);
|
||||||
|
const connectorsItem = useMemo(
|
||||||
|
() => navItems.find((item) => item.url.endsWith("/connectors")),
|
||||||
|
[navItems]
|
||||||
|
);
|
||||||
const playgroundItem = useMemo(
|
const playgroundItem = useMemo(
|
||||||
() => navItems.find((item) => item.url.endsWith("/playground")),
|
() => navItems.find((item) => item.url.endsWith("/playground")),
|
||||||
[navItems]
|
[navItems]
|
||||||
|
|
@ -150,6 +154,7 @@ export function Sidebar({
|
||||||
(item) =>
|
(item) =>
|
||||||
!item.url.endsWith("/automations") &&
|
!item.url.endsWith("/automations") &&
|
||||||
!item.url.endsWith("/artifacts") &&
|
!item.url.endsWith("/artifacts") &&
|
||||||
|
!item.url.endsWith("/connectors") &&
|
||||||
!item.url.endsWith("/playground")
|
!item.url.endsWith("/playground")
|
||||||
),
|
),
|
||||||
[navItems]
|
[navItems]
|
||||||
|
|
@ -254,6 +259,16 @@ export function Sidebar({
|
||||||
tooltipContent={isCollapsed ? artifactsItem.title : undefined}
|
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 && (
|
{playgroundItem && (
|
||||||
<SidebarButton
|
<SidebarButton
|
||||||
icon={playgroundItem.icon}
|
icon={playgroundItem.icon}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,16 @@ interface PublicChatSnapshotsManagerProps {
|
||||||
workspaceId: number;
|
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({
|
export function PublicChatSnapshotsManager({
|
||||||
workspaceId: _workspaceId,
|
workspaceId: _workspaceId,
|
||||||
}: PublicChatSnapshotsManagerProps) {
|
}: PublicChatSnapshotsManagerProps) {
|
||||||
|
|
@ -81,14 +91,7 @@ export function PublicChatSnapshotsManager({
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-5">
|
<div className="space-y-4 md:space-y-5">
|
||||||
<Alert>
|
{infoAlert}
|
||||||
<Info />
|
|
||||||
<AlertDescription>
|
|
||||||
<div className="flex min-h-[1.625em] items-center">
|
|
||||||
<Skeleton className="h-4 w-60 bg-accent-foreground/15" />
|
|
||||||
</div>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
{/* Cards grid skeleton */}
|
{/* Cards grid skeleton */}
|
||||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2">
|
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2">
|
||||||
|
|
@ -135,13 +138,7 @@ export function PublicChatSnapshotsManager({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-5">
|
<div className="space-y-4 md:space-y-5">
|
||||||
<Alert>
|
{infoAlert}
|
||||||
<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>
|
|
||||||
|
|
||||||
<PublicChatSnapshotsList
|
<PublicChatSnapshotsList
|
||||||
snapshots={snapshots}
|
snapshots={snapshots}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, ExternalLink } from "lucide-react";
|
import { Check, ExternalLink } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
|
||||||
import { Button } from "@/components/ui/button";
|
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 type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
||||||
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||||
import {
|
import { trackIncentiveTaskClicked } from "@/lib/posthog/events";
|
||||||
trackIncentivePageViewed,
|
|
||||||
trackIncentiveTaskClicked,
|
|
||||||
trackIncentiveTaskCompleted,
|
|
||||||
} from "@/lib/posthog/events";
|
|
||||||
import { getWorkspaceIdParam } from "@/lib/route-params";
|
import { getWorkspaceIdParam } from "@/lib/route-params";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -35,9 +30,7 @@ export function EarnCreditsContent() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const workspaceId = getWorkspaceIdParam(params) ?? "";
|
const workspaceId = getWorkspaceIdParam(params) ?? "";
|
||||||
|
|
||||||
useEffect(() => {
|
// incentive_page_viewed removed — redundant with $pageview.
|
||||||
trackIncentivePageViewed();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["incentive-tasks"],
|
queryKey: ["incentive-tasks"],
|
||||||
|
|
@ -51,13 +44,11 @@ export function EarnCreditsContent() {
|
||||||
|
|
||||||
const completeMutation = useMutation({
|
const completeMutation = useMutation({
|
||||||
mutationFn: incentiveTasksApiService.completeTask,
|
mutationFn: incentiveTasksApiService.completeTask,
|
||||||
onSuccess: (response, taskType) => {
|
onSuccess: (response) => {
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
toast.success(response.message);
|
toast.success(response.message);
|
||||||
const task = data?.tasks.find((t) => t.task_type === taskType);
|
// incentive_task_completed is now emitted server-side
|
||||||
if (task) {
|
// (incentive_tasks_routes.complete_task) where credit is granted.
|
||||||
trackIncentiveTaskCompleted(taskType, task.credit_micros_reward);
|
|
||||||
}
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
||||||
queryClient.invalidateQueries({ queryKey: USER_QUERY_KEY });
|
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 type { ProcessingMode } from "@/contracts/types/document.types";
|
||||||
import { useElectronAPI } from "@/hooks/use-platform";
|
import { useElectronAPI } from "@/hooks/use-platform";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
import {
|
import { trackDocumentUploadStarted } from "@/lib/posthog/events";
|
||||||
trackDocumentUploadFailure,
|
|
||||||
trackDocumentUploadStarted,
|
|
||||||
trackDocumentUploadSuccess,
|
|
||||||
} from "@/lib/posthog/events";
|
|
||||||
import {
|
import {
|
||||||
getAcceptedFileTypes,
|
getAcceptedFileTypes,
|
||||||
getSupportedExtensions,
|
getSupportedExtensions,
|
||||||
|
|
@ -380,13 +376,14 @@ export function DocumentUploadTab({
|
||||||
setUploadProgress(Math.round((uploaded / total) * 100));
|
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") });
|
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
|
||||||
setFolderUpload(null);
|
setFolderUpload(null);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Upload failed";
|
const message = error instanceof Error ? error.message : "Upload failed";
|
||||||
trackDocumentUploadFailure(Number(workspaceId), message);
|
|
||||||
toast(t("upload_error"), {
|
toast(t("upload_error"), {
|
||||||
description: `${t("upload_error_desc")}: ${message}`,
|
description: `${t("upload_error_desc")}: ${message}`,
|
||||||
});
|
});
|
||||||
|
|
@ -421,7 +418,7 @@ export function DocumentUploadTab({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
trackDocumentUploadSuccess(Number(workspaceId), files.length);
|
// Ingestion outcome now server-side (document_processing_*).
|
||||||
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
|
toast(t("upload_initiated"), { description: t("upload_initiated_desc") });
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
},
|
},
|
||||||
|
|
@ -429,7 +426,6 @@ export function DocumentUploadTab({
|
||||||
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
const message = error instanceof Error ? error.message : "Upload failed";
|
const message = error instanceof Error ? error.message : "Upload failed";
|
||||||
trackDocumentUploadFailure(Number(workspaceId), message);
|
|
||||||
toast(t("upload_error"), {
|
toast(t("upload_error"), {
|
||||||
description: `${t("upload_error_desc")}: ${message}`,
|
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
|
## 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.
|
- **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.
|
- **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.
|
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.
|
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.
|
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
|
## Out Of Scope
|
||||||
|
|
||||||
- Frontend/browser OpenTelemetry.
|
- Frontend/browser OpenTelemetry.
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,17 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
||||||
case EnumConnectorName.CIRCLEBACK_CONNECTOR:
|
case EnumConnectorName.CIRCLEBACK_CONNECTOR:
|
||||||
return <Image src="/connectors/circleback.svg" alt="Circleback" {...imgProps} />;
|
return <Image src="/connectors/circleback.svg" alt="Circleback" {...imgProps} />;
|
||||||
case EnumConnectorName.MCP_CONNECTOR:
|
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:
|
case EnumConnectorName.OBSIDIAN_CONNECTOR:
|
||||||
return <Image src="/connectors/obsidian.svg" alt="Obsidian" {...imgProps} />;
|
return <Image src="/connectors/obsidian.svg" alt="Obsidian" {...imgProps} />;
|
||||||
case EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR:
|
case EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSetAtom } from "jotai";
|
import { useSetAtom } from "jotai";
|
||||||
import { Boxes, RefreshCw, TriangleAlert } from "lucide-react";
|
import { RefreshCw, Shapes, TriangleAlert } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
import { openReportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||||
import { MobileReportPanel } from "@/components/report-panel/report-panel";
|
import { MobileReportPanel } from "@/components/report-panel/report-panel";
|
||||||
|
|
@ -48,7 +48,7 @@ function EmptyState() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-12 text-center">
|
<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">
|
<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>
|
</div>
|
||||||
<h3 className="mt-4 text-base font-semibold text-foreground">No artifacts yet</h3>
|
<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">
|
<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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { ScraperRunDetail, ScraperRunEvent } from "@/contracts/types/scraper.types";
|
import type { ScraperRunDetail, ScraperRunEvent } from "@/contracts/types/scraper.types";
|
||||||
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
|
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
|
||||||
import { trackWeeklyUser } from "@/lib/posthog/events";
|
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
export type RunStatus = "idle" | "running" | "success" | "error" | "cancelled";
|
export type RunStatus = "idle" | "running" | "success" | "error" | "cancelled";
|
||||||
|
|
@ -110,7 +109,8 @@ export function useRunStream(workspaceId: number) {
|
||||||
try {
|
try {
|
||||||
const started = await scrapersApiService.runAsync(workspaceId, platform, verb, payload);
|
const started = await scrapersApiService.runAsync(workspaceId, platform, verb, payload);
|
||||||
runIdRef.current = started.run_id;
|
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 }));
|
setState((s) => ({ ...s, runId: started.run_id }));
|
||||||
void consume(started.run_id, controller.signal);
|
void consume(started.run_id, controller.signal);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,30 @@ function blockRefreshRetry(key: string): void {
|
||||||
refreshRetryBlockedUntil.set(key, Date.now() + REFRESH_RETRY_BLOCK_MS);
|
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 = {
|
export type RequestOptions = {
|
||||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
|
@ -288,9 +312,12 @@ class BaseApiService {
|
||||||
throw new AbortedError();
|
throw new AbortedError();
|
||||||
}
|
}
|
||||||
if (error instanceof TypeError && !(error instanceof AppError)) {
|
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."
|
"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
|
// Handled client errors (validation, credits, auth, not-found, ...) are
|
||||||
|
|
@ -305,23 +332,10 @@ class BaseApiService {
|
||||||
|
|
||||||
if (!isHandledClientError) {
|
if (!isHandledClientError) {
|
||||||
console.error("Request failed:", JSON.stringify(error));
|
console.error("Request failed:", JSON.stringify(error));
|
||||||
if (!(error instanceof AuthenticationError)) {
|
// Only 5xx server faults are unexpected; 4xx are handled above and
|
||||||
import("posthog-js")
|
// network outages were already captured before this point.
|
||||||
.then(({ default: posthog }) => {
|
if (error instanceof AppError && error.status >= 500) {
|
||||||
posthog.captureException(error, {
|
captureApiException(error, url, options?.method);
|
||||||
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");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ import {
|
||||||
import { buildBackendUrl } from "@/lib/env-config";
|
import { buildBackendUrl } from "@/lib/env-config";
|
||||||
import {
|
import {
|
||||||
trackChatBlocked,
|
trackChatBlocked,
|
||||||
trackChatCreated,
|
|
||||||
trackChatErrorDetailed,
|
trackChatErrorDetailed,
|
||||||
trackChatMessageSent,
|
trackChatMessageSent,
|
||||||
trackChatResponseReceived,
|
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.detail(newThread.id), newThread);
|
||||||
queryClient.setQueryData(cacheKeys.threads.messages(newThread.id), { messages: [] });
|
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;
|
isNewThread = true;
|
||||||
// Update URL silently using browser API (not router.replace) to avoid
|
// 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";
|
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
|
* All capture/identify/reset calls are wrapped in try-catch so that
|
||||||
* ad-blockers that interfere with posthog-js can never break app
|
* ad-blockers that interfere with posthog-js can never break app
|
||||||
* functionality (e.g. the chat flow).
|
* 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:
|
* Events follow a consistent naming convention: category_action
|
||||||
* - 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
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function safeCapture(event: string, properties?: Record<string, unknown>) {
|
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") {
|
export function trackLoginAttempt(method: "local" | "google") {
|
||||||
safeCapture("auth_login_attempt", { method });
|
safeCapture("auth_login_attempt", { method });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackLoginSuccess(method: "local" | "google") {
|
|
||||||
safeCapture("auth_login_success", { method });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trackLoginFailure(method: "local" | "google", error?: string) {
|
export function trackLoginFailure(method: "local" | "google", error?: string) {
|
||||||
safeCapture("auth_login_failure", { method, error });
|
safeCapture("auth_login_failure", { method, error });
|
||||||
}
|
}
|
||||||
|
|
@ -62,10 +54,6 @@ export function trackRegistrationAttempt() {
|
||||||
safeCapture("auth_registration_attempt");
|
safeCapture("auth_registration_attempt");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackRegistrationSuccess() {
|
|
||||||
safeCapture("auth_registration_success");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trackRegistrationFailure(error?: string) {
|
export function trackRegistrationFailure(error?: string) {
|
||||||
safeCapture("auth_registration_failure", { error });
|
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(
|
export function trackChatMessageSent(
|
||||||
workspaceId: number,
|
workspaceId: number,
|
||||||
chatId: number,
|
chatId: number,
|
||||||
|
|
@ -141,7 +82,6 @@ export function trackChatMessageSent(
|
||||||
has_mentioned_documents: options?.hasMentionedDocuments ?? false,
|
has_mentioned_documents: options?.hasMentionedDocuments ?? false,
|
||||||
message_length: options?.messageLength,
|
message_length: options?.messageLength,
|
||||||
});
|
});
|
||||||
trackWeeklyUser("chat_message", workspaceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function trackChatResponseReceived(workspaceId: number, chatId: number) {
|
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`
|
* flow. This is intentionally a separate event from `chat_message_sent`
|
||||||
* so WAU / retention queries on the authenticated event stay clean while
|
* so WAU / retention queries on the authenticated event stay clean while
|
||||||
* still giving us visibility into top-of-funnel usage on /free/*.
|
* 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: {
|
export function trackAnonymousChatMessageSent(options: {
|
||||||
modelSlug: string;
|
modelSlug: string;
|
||||||
|
|
@ -229,7 +173,7 @@ export function trackAnonymousChatMessageSent(options: {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// DOCUMENT EVENTS
|
// DOCUMENT EVENTS (intent only; ingestion outcome is server-side)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export function trackDocumentUploadStarted(
|
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
|
// All connector events go through `trackConnectorEvent`. The connector's
|
||||||
// human-readable title and its group (oauth/composio/crawler/other) are
|
// group (oauth/composio/crawler/other) is auto-attached from the shared
|
||||||
// auto-attached from the shared registry in `connector-constants.ts`, so
|
// registry, so adding a new connector to that list is the only change
|
||||||
// adding a new connector to that list is the only change required for it
|
// required for it to show up correctly in PostHog dashboards.
|
||||||
// to show up correctly in PostHog dashboards.
|
|
||||||
|
|
||||||
export type ConnectorEventStage =
|
export type ConnectorEventStage =
|
||||||
| "setup_started"
|
| "setup_started"
|
||||||
| "setup_success"
|
| "setup_success"
|
||||||
| "setup_failure"
|
| "setup_failure"
|
||||||
| "oauth_initiated"
|
| "oauth_initiated";
|
||||||
| "connected"
|
|
||||||
| "deleted"
|
|
||||||
| "synced";
|
|
||||||
|
|
||||||
export interface ConnectorEventOptions {
|
export interface ConnectorEventOptions {
|
||||||
workspaceId?: number | null;
|
workspaceId?: number | null;
|
||||||
|
|
@ -312,6 +217,9 @@ export interface ConnectorEventOptions {
|
||||||
/**
|
/**
|
||||||
* Generic connector lifecycle tracker. Every connector analytics event
|
* Generic connector lifecycle tracker. Every connector analytics event
|
||||||
* should funnel through here so the enrichment stays consistent.
|
* 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(
|
export function trackConnectorEvent(
|
||||||
stage: ConnectorEventStage,
|
stage: ConnectorEventStage,
|
||||||
|
|
@ -327,7 +235,6 @@ export function trackConnectorEvent(
|
||||||
error: options.error,
|
error: options.error,
|
||||||
}),
|
}),
|
||||||
connector_type: meta.connector_type,
|
connector_type: meta.connector_type,
|
||||||
connector_title: meta.connector_title,
|
|
||||||
connector_group: meta.connector_group,
|
connector_group: meta.connector_group,
|
||||||
is_oauth: meta.is_oauth,
|
is_oauth: meta.is_oauth,
|
||||||
...(options.extra ?? {}),
|
...(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
|
// 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: {
|
export function trackDesktopDownloadClicked(options: {
|
||||||
os: string;
|
os: string;
|
||||||
placement: "sidebar_collapsed" | "sidebar_expanded";
|
placement: "sidebar_collapsed" | "sidebar_expanded";
|
||||||
|
|
@ -429,84 +287,7 @@ export function trackDesktopDownloadClicked(options: {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// SEARCH SPACE INVITE EVENTS
|
// INDEXING EVENTS (intent/UX; indexing outcome is server-side)
|
||||||
// ============================================
|
|
||||||
|
|
||||||
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
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
export function trackIndexWithDateRangeOpened(
|
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() {
|
export function trackWorkspaceInviteDeclined(workspaceName?: string) {
|
||||||
safeCapture("incentive_page_viewed");
|
safeCapture("workspace_invite_declined", {
|
||||||
}
|
workspace_name: workspaceName,
|
||||||
|
|
||||||
export function trackIncentiveTaskCompleted(taskType: string, creditMicrosRewarded: number) {
|
|
||||||
safeCapture("incentive_task_completed", {
|
|
||||||
task_type: taskType,
|
|
||||||
credit_micros_rewarded: creditMicrosRewarded,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// INCENTIVE TASKS EVENTS (click intent only; completion is server-side)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
export function trackIncentiveTaskClicked(taskType: string) {
|
export function trackIncentiveTaskClicked(taskType: string) {
|
||||||
safeCapture("incentive_task_clicked", {
|
safeCapture("incentive_task_clicked", {
|
||||||
task_type: taskType,
|
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 }) {
|
export function trackAutomationCreateFailed(props: { workspace_id?: number; error?: string }) {
|
||||||
safeCapture("automation_create_failed", compact(props));
|
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 }) {
|
export function trackAutomationUpdateFailed(props: { automation_id: number; error?: string }) {
|
||||||
safeCapture("automation_update_failed", compact(props));
|
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 }) {
|
export function trackAutomationDeleteFailed(props: { automation_id: number; error?: string }) {
|
||||||
safeCapture("automation_delete_failed", compact(props));
|
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 }) {
|
export function trackAutomationTriggerAddFailed(props: { automation_id: number; error?: string }) {
|
||||||
safeCapture("automation_trigger_add_failed", compact(props));
|
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: {
|
export function trackAutomationTriggerUpdateFailed(props: {
|
||||||
automation_id: number;
|
automation_id: number;
|
||||||
trigger_id: number;
|
trigger_id: number;
|
||||||
|
|
@ -701,13 +423,6 @@ export function trackAutomationTriggerUpdateFailed(props: {
|
||||||
safeCapture("automation_trigger_update_failed", compact(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: {
|
export function trackAutomationTriggerRemoveFailed(props: {
|
||||||
automation_id: number;
|
automation_id: number;
|
||||||
trigger_id: number;
|
trigger_id: number;
|
||||||
|
|
|
||||||
|
|
@ -664,7 +664,6 @@
|
||||||
"chat_deleted": "Chat deleted successfully",
|
"chat_deleted": "Chat deleted successfully",
|
||||||
"error_deleting_chat": "Failed to delete chat",
|
"error_deleting_chat": "Failed to delete chat",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"try_different_search": "Try a different search term",
|
|
||||||
"updated": "Updated",
|
"updated": "Updated",
|
||||||
"more_options": "More options",
|
"more_options": "More options",
|
||||||
"clear_search": "Clear search",
|
"clear_search": "Clear search",
|
||||||
|
|
|
||||||
|
|
@ -664,7 +664,6 @@
|
||||||
"chat_deleted": "Chat eliminado correctamente",
|
"chat_deleted": "Chat eliminado correctamente",
|
||||||
"error_deleting_chat": "Error al eliminar el chat",
|
"error_deleting_chat": "Error al eliminar el chat",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"try_different_search": "Intenta con un término de búsqueda diferente",
|
|
||||||
"updated": "Actualizado",
|
"updated": "Actualizado",
|
||||||
"more_options": "Más opciones",
|
"more_options": "Más opciones",
|
||||||
"clear_search": "Limpiar búsqueda",
|
"clear_search": "Limpiar búsqueda",
|
||||||
|
|
|
||||||
|
|
@ -664,7 +664,6 @@
|
||||||
"chat_deleted": "चैट सफलतापूर्वक हटाया गया",
|
"chat_deleted": "चैट सफलतापूर्वक हटाया गया",
|
||||||
"error_deleting_chat": "चैट हटाने में विफल",
|
"error_deleting_chat": "चैट हटाने में विफल",
|
||||||
"delete": "हटाएं",
|
"delete": "हटाएं",
|
||||||
"try_different_search": "कोई अलग खोज शब्द आज़माएं",
|
|
||||||
"updated": "अपडेट किया गया",
|
"updated": "अपडेट किया गया",
|
||||||
"more_options": "और विकल्प",
|
"more_options": "और विकल्प",
|
||||||
"clear_search": "खोज साफ करें",
|
"clear_search": "खोज साफ करें",
|
||||||
|
|
|
||||||
|
|
@ -664,7 +664,6 @@
|
||||||
"chat_deleted": "채팅이 성공적으로 삭제되었습니다",
|
"chat_deleted": "채팅이 성공적으로 삭제되었습니다",
|
||||||
"error_deleting_chat": "채팅 삭제에 실패했습니다",
|
"error_deleting_chat": "채팅 삭제에 실패했습니다",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"try_different_search": "다른 검색어를 시도해보세요",
|
|
||||||
"updated": "업데이트됨",
|
"updated": "업데이트됨",
|
||||||
"more_options": "추가 옵션",
|
"more_options": "추가 옵션",
|
||||||
"clear_search": "검색 지우기",
|
"clear_search": "검색 지우기",
|
||||||
|
|
|
||||||
|
|
@ -664,7 +664,6 @@
|
||||||
"chat_deleted": "Chat excluído com sucesso",
|
"chat_deleted": "Chat excluído com sucesso",
|
||||||
"error_deleting_chat": "Falha ao excluir chat",
|
"error_deleting_chat": "Falha ao excluir chat",
|
||||||
"delete": "Excluir",
|
"delete": "Excluir",
|
||||||
"try_different_search": "Tente um termo de pesquisa diferente",
|
|
||||||
"updated": "Atualizado",
|
"updated": "Atualizado",
|
||||||
"more_options": "Mais opções",
|
"more_options": "Mais opções",
|
||||||
"clear_search": "Limpar pesquisa",
|
"clear_search": "Limpar pesquisa",
|
||||||
|
|
|
||||||
|
|
@ -663,7 +663,6 @@
|
||||||
"chat_deleted": "对话删除成功",
|
"chat_deleted": "对话删除成功",
|
||||||
"error_deleting_chat": "删除对话失败",
|
"error_deleting_chat": "删除对话失败",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"try_different_search": "尝试其他搜索词",
|
|
||||||
"updated": "更新时间",
|
"updated": "更新时间",
|
||||||
"more_options": "更多选项",
|
"more_options": "更多选项",
|
||||||
"clear_search": "清除搜索",
|
"clear_search": "清除搜索",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue