mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/docker-host-gateway
This commit is contained in:
commit
f06e00d77c
28 changed files with 1252 additions and 155 deletions
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { BrainCog, Rocket, Zap } from "lucide-react";
|
||||
import { BrainCog, Power, Rocket, Zap } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder";
|
||||
|
|
@ -30,6 +30,10 @@ export function DesktopContent() {
|
|||
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
||||
const [activeSpaceId, setActiveSpaceId] = useState<string | null>(null);
|
||||
|
||||
const [autoLaunchEnabled, setAutoLaunchEnabled] = useState(false);
|
||||
const [autoLaunchHidden, setAutoLaunchHidden] = useState(true);
|
||||
const [autoLaunchSupported, setAutoLaunchSupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
setLoading(false);
|
||||
|
|
@ -38,19 +42,28 @@ export function DesktopContent() {
|
|||
}
|
||||
|
||||
let mounted = true;
|
||||
const hasAutoLaunchApi =
|
||||
typeof api.getAutoLaunch === "function" && typeof api.setAutoLaunch === "function";
|
||||
setAutoLaunchSupported(hasAutoLaunchApi);
|
||||
|
||||
Promise.all([
|
||||
api.getAutocompleteEnabled(),
|
||||
api.getShortcuts?.() ?? Promise.resolve(null),
|
||||
api.getActiveSearchSpace?.() ?? Promise.resolve(null),
|
||||
searchSpacesApiService.getSearchSpaces(),
|
||||
hasAutoLaunchApi ? api.getAutoLaunch() : Promise.resolve(null),
|
||||
])
|
||||
.then(([autoEnabled, config, spaceId, spaces]) => {
|
||||
.then(([autoEnabled, config, spaceId, spaces, autoLaunch]) => {
|
||||
if (!mounted) return;
|
||||
setEnabled(autoEnabled);
|
||||
if (config) setShortcuts(config);
|
||||
setActiveSpaceId(spaceId);
|
||||
if (spaces) setSearchSpaces(spaces);
|
||||
if (autoLaunch) {
|
||||
setAutoLaunchEnabled(autoLaunch.enabled);
|
||||
setAutoLaunchHidden(autoLaunch.openAsHidden);
|
||||
setAutoLaunchSupported(autoLaunch.supported);
|
||||
}
|
||||
setLoading(false);
|
||||
setShortcutsLoaded(true);
|
||||
})
|
||||
|
|
@ -106,6 +119,40 @@ export function DesktopContent() {
|
|||
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
||||
};
|
||||
|
||||
const handleAutoLaunchToggle = async (checked: boolean) => {
|
||||
if (!autoLaunchSupported || !api.setAutoLaunch) {
|
||||
toast.error("Please update the desktop app to configure launch on startup");
|
||||
return;
|
||||
}
|
||||
setAutoLaunchEnabled(checked);
|
||||
try {
|
||||
const next = await api.setAutoLaunch(checked, autoLaunchHidden);
|
||||
if (next) {
|
||||
setAutoLaunchEnabled(next.enabled);
|
||||
setAutoLaunchHidden(next.openAsHidden);
|
||||
setAutoLaunchSupported(next.supported);
|
||||
}
|
||||
toast.success(checked ? "SurfSense will launch on startup" : "Launch on startup disabled");
|
||||
} catch {
|
||||
setAutoLaunchEnabled(!checked);
|
||||
toast.error("Failed to update launch on startup");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoLaunchHiddenToggle = async (checked: boolean) => {
|
||||
if (!autoLaunchSupported || !api.setAutoLaunch) {
|
||||
toast.error("Please update the desktop app to configure startup behavior");
|
||||
return;
|
||||
}
|
||||
setAutoLaunchHidden(checked);
|
||||
try {
|
||||
await api.setAutoLaunch(autoLaunchEnabled, checked);
|
||||
} catch {
|
||||
setAutoLaunchHidden(!checked);
|
||||
toast.error("Failed to update startup behavior");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchSpaceChange = (value: string) => {
|
||||
setActiveSpaceId(value);
|
||||
api.setActiveSearchSpace?.(value);
|
||||
|
|
@ -145,6 +192,60 @@ export function DesktopContent() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Launch on Startup */}
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg flex items-center gap-2">
|
||||
<Power className="h-4 w-4" />
|
||||
Launch on Startup
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Automatically start SurfSense when you sign in to your computer so global
|
||||
shortcuts and folder sync are always available.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6 space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-launch-toggle" className="text-sm font-medium cursor-pointer">
|
||||
Open SurfSense at login
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{autoLaunchSupported
|
||||
? "Adds SurfSense to your system's login items."
|
||||
: "Only available in the packaged desktop app."}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-launch-toggle"
|
||||
checked={autoLaunchEnabled}
|
||||
onCheckedChange={handleAutoLaunchToggle}
|
||||
disabled={!autoLaunchSupported}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label
|
||||
htmlFor="auto-launch-hidden-toggle"
|
||||
className="text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Start minimized to tray
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Skip the main window on boot — SurfSense lives in the system tray until you need
|
||||
it.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-launch-hidden-toggle"
|
||||
checked={autoLaunchHidden}
|
||||
onCheckedChange={handleAutoLaunchHiddenToggle}
|
||||
disabled={!autoLaunchSupported || !autoLaunchEnabled}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ReceiptText } from "lucide-react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { Coins, FileText, ReceiptText } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
|
|
@ -12,10 +13,26 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { PagePurchase, PagePurchaseStatus } from "@/contracts/types/stripe.types";
|
||||
import type {
|
||||
PagePurchase,
|
||||
PagePurchaseStatus,
|
||||
TokenPurchase,
|
||||
} from "@/contracts/types/stripe.types";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PurchaseKind = "pages" | "tokens";
|
||||
|
||||
type UnifiedPurchase = {
|
||||
id: string;
|
||||
kind: PurchaseKind;
|
||||
created_at: string;
|
||||
status: PagePurchaseStatus;
|
||||
granted: number;
|
||||
amount_total: number | null;
|
||||
currency: string | null;
|
||||
};
|
||||
|
||||
const STATUS_STYLES: Record<PagePurchaseStatus, { label: string; className: string }> = {
|
||||
completed: {
|
||||
label: "Completed",
|
||||
|
|
@ -31,6 +48,22 @@ const STATUS_STYLES: Record<PagePurchaseStatus, { label: string; className: stri
|
|||
},
|
||||
};
|
||||
|
||||
const KIND_META: Record<
|
||||
PurchaseKind,
|
||||
{ label: string; icon: React.ComponentType<{ className?: string }>; iconClass: string }
|
||||
> = {
|
||||
pages: {
|
||||
label: "Pages",
|
||||
icon: FileText,
|
||||
iconClass: "text-sky-500",
|
||||
},
|
||||
tokens: {
|
||||
label: "Premium Tokens",
|
||||
icon: Coins,
|
||||
iconClass: "text-amber-500",
|
||||
},
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
|
|
@ -39,19 +72,65 @@ function formatDate(iso: string): string {
|
|||
});
|
||||
}
|
||||
|
||||
function formatAmount(purchase: PagePurchase): string {
|
||||
if (purchase.amount_total == null) return "—";
|
||||
const dollars = purchase.amount_total / 100;
|
||||
const currency = (purchase.currency ?? "usd").toUpperCase();
|
||||
return `$${dollars.toFixed(2)} ${currency}`;
|
||||
function formatAmount(amount: number | null, currency: string | null): string {
|
||||
if (amount == null) return "—";
|
||||
const dollars = amount / 100;
|
||||
const code = (currency ?? "usd").toUpperCase();
|
||||
return `$${dollars.toFixed(2)} ${code}`;
|
||||
}
|
||||
|
||||
function normalizePagePurchase(p: PagePurchase): UnifiedPurchase {
|
||||
return {
|
||||
id: p.id,
|
||||
kind: "pages",
|
||||
created_at: p.created_at,
|
||||
status: p.status,
|
||||
granted: p.pages_granted,
|
||||
amount_total: p.amount_total,
|
||||
currency: p.currency,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTokenPurchase(p: TokenPurchase): UnifiedPurchase {
|
||||
return {
|
||||
id: p.id,
|
||||
kind: "tokens",
|
||||
created_at: p.created_at,
|
||||
status: p.status,
|
||||
granted: p.tokens_granted,
|
||||
amount_total: p.amount_total,
|
||||
currency: p.currency,
|
||||
};
|
||||
}
|
||||
|
||||
export function PurchaseHistoryContent() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["stripe-purchases"],
|
||||
queryFn: () => stripeApiService.getPurchases(),
|
||||
const results = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ["stripe-purchases"],
|
||||
queryFn: () => stripeApiService.getPurchases(),
|
||||
},
|
||||
{
|
||||
queryKey: ["stripe-token-purchases"],
|
||||
queryFn: () => stripeApiService.getTokenPurchases(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [pagesQuery, tokensQuery] = results;
|
||||
const isLoading = pagesQuery.isLoading || tokensQuery.isLoading;
|
||||
|
||||
const purchases = useMemo<UnifiedPurchase[]>(() => {
|
||||
const pagePurchases = pagesQuery.data?.purchases ?? [];
|
||||
const tokenPurchases = tokensQuery.data?.purchases ?? [];
|
||||
return [
|
||||
...pagePurchases.map(normalizePagePurchase),
|
||||
...tokenPurchases.map(normalizeTokenPurchase),
|
||||
].sort(
|
||||
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
);
|
||||
}, [pagesQuery.data, tokensQuery.data]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
|
|
@ -60,15 +139,13 @@ export function PurchaseHistoryContent() {
|
|||
);
|
||||
}
|
||||
|
||||
const purchases = data?.purchases ?? [];
|
||||
|
||||
if (purchases.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||
<ReceiptText className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No purchases yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your page-pack purchases will appear here after checkout.
|
||||
Your page and premium token purchases will appear here after checkout.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -81,25 +158,36 @@ export function PurchaseHistoryContent() {
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Pages</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Granted</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{purchases.map((p) => {
|
||||
const style = STATUS_STYLES[p.status];
|
||||
const statusStyle = STATUS_STYLES[p.status];
|
||||
const kind = KIND_META[p.kind];
|
||||
const KindIcon = kind.icon;
|
||||
return (
|
||||
<TableRow key={p.id}>
|
||||
<TableRow key={`${p.kind}-${p.id}`}>
|
||||
<TableCell className="text-sm">{formatDate(p.created_at)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{p.pages_granted.toLocaleString()}
|
||||
<TableCell className="text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<KindIcon className={cn("h-4 w-4", kind.iconClass)} />
|
||||
<span>{kind.label}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{formatAmount(p)}
|
||||
{p.granted.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{formatAmount(p.amount_total, p.currency)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge className={cn("text-[10px]", style.className)}>{style.label}</Badge>
|
||||
<Badge className={cn("text-[10px]", statusStyle.className)}>
|
||||
{statusStyle.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
|
@ -108,7 +196,8 @@ export function PurchaseHistoryContent() {
|
|||
</Table>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Showing your {purchases.length} most recent purchase{purchases.length !== 1 ? "s" : ""}.
|
||||
Showing your {purchases.length} most recent purchase
|
||||
{purchases.length !== 1 ? "s" : ""}.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -340,5 +340,85 @@ export const AUTO_INDEX_DEFAULTS: Record<string, AutoIndexConfig> = {
|
|||
|
||||
export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(Object.keys(AUTO_INDEX_DEFAULTS));
|
||||
|
||||
// ============================================================================
|
||||
// CONNECTOR TELEMETRY REGISTRY
|
||||
// ----------------------------------------------------------------------------
|
||||
// Single source of truth for "what does this connector_type look like in
|
||||
// analytics?". Any connector added to the lists above is automatically
|
||||
// picked up here, so adding a new integration does NOT require touching
|
||||
// `lib/posthog/events.ts` or per-connector tracking code.
|
||||
// ============================================================================
|
||||
|
||||
export type ConnectorTelemetryGroup =
|
||||
| "oauth"
|
||||
| "composio"
|
||||
| "crawler"
|
||||
| "other"
|
||||
| "unknown";
|
||||
|
||||
export interface ConnectorTelemetryMeta {
|
||||
connector_type: string;
|
||||
connector_title: string;
|
||||
connector_group: ConnectorTelemetryGroup;
|
||||
is_oauth: boolean;
|
||||
}
|
||||
|
||||
const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap<string, ConnectorTelemetryMeta> =
|
||||
(() => {
|
||||
const map = new Map<string, ConnectorTelemetryMeta>();
|
||||
|
||||
for (const c of OAUTH_CONNECTORS) {
|
||||
map.set(c.connectorType, {
|
||||
connector_type: c.connectorType,
|
||||
connector_title: c.title,
|
||||
connector_group: "oauth",
|
||||
is_oauth: true,
|
||||
});
|
||||
}
|
||||
for (const c of COMPOSIO_CONNECTORS) {
|
||||
map.set(c.connectorType, {
|
||||
connector_type: c.connectorType,
|
||||
connector_title: c.title,
|
||||
connector_group: "composio",
|
||||
is_oauth: true,
|
||||
});
|
||||
}
|
||||
for (const c of CRAWLERS) {
|
||||
map.set(c.connectorType, {
|
||||
connector_type: c.connectorType,
|
||||
connector_title: c.title,
|
||||
connector_group: "crawler",
|
||||
is_oauth: false,
|
||||
});
|
||||
}
|
||||
for (const c of OTHER_CONNECTORS) {
|
||||
map.set(c.connectorType, {
|
||||
connector_type: c.connectorType,
|
||||
connector_title: c.title,
|
||||
connector_group: "other",
|
||||
is_oauth: false,
|
||||
});
|
||||
}
|
||||
|
||||
return map;
|
||||
})();
|
||||
|
||||
/**
|
||||
* Returns telemetry metadata for a connector_type, or a minimal "unknown"
|
||||
* record so tracking never no-ops for connectors that exist in the backend
|
||||
* but were forgotten in the UI registry.
|
||||
*/
|
||||
export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelemetryMeta {
|
||||
const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType);
|
||||
if (hit) return hit;
|
||||
|
||||
return {
|
||||
connector_type: connectorType,
|
||||
connector_title: connectorType,
|
||||
connector_group: "unknown",
|
||||
is_oauth: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export IndexingConfigState from schemas for backward compatibility
|
||||
export type { IndexingConfigState } from "./connector-popup.schemas";
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { authenticatedFetch } from "@/lib/auth-utils";
|
|||
import {
|
||||
trackConnectorConnected,
|
||||
trackConnectorDeleted,
|
||||
trackConnectorSetupFailure,
|
||||
trackConnectorSetupStarted,
|
||||
trackIndexWithDateRangeOpened,
|
||||
trackIndexWithDateRangeStarted,
|
||||
trackPeriodicIndexingStarted,
|
||||
|
|
@ -222,10 +224,20 @@ export const useConnectorDialog = () => {
|
|||
|
||||
if (result.error) {
|
||||
const oauthConnector = result.connector
|
||||
? OAUTH_CONNECTORS.find((c) => c.id === result.connector)
|
||||
? OAUTH_CONNECTORS.find((c) => c.id === result.connector) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.id === result.connector)
|
||||
: null;
|
||||
const name = oauthConnector?.title || "connector";
|
||||
|
||||
if (oauthConnector) {
|
||||
trackConnectorSetupFailure(
|
||||
Number(searchSpaceId),
|
||||
oauthConnector.connectorType,
|
||||
result.error,
|
||||
"oauth_callback"
|
||||
);
|
||||
}
|
||||
|
||||
if (result.error === "duplicate_account") {
|
||||
toast.error(`This ${name} account is already connected`, {
|
||||
description: "Please use a different account or manage the existing connection.",
|
||||
|
|
@ -338,6 +350,12 @@ export const useConnectorDialog = () => {
|
|||
// Set connecting state immediately to disable button and show spinner
|
||||
setConnectingId(connector.id);
|
||||
|
||||
trackConnectorSetupStarted(
|
||||
Number(searchSpaceId),
|
||||
connector.connectorType,
|
||||
"oauth_click"
|
||||
);
|
||||
|
||||
try {
|
||||
// Check if authEndpoint already has query parameters
|
||||
const separator = connector.authEndpoint.includes("?") ? "&" : "?";
|
||||
|
|
@ -359,6 +377,12 @@ export const useConnectorDialog = () => {
|
|||
window.location.href = validatedData.auth_url;
|
||||
} catch (error) {
|
||||
console.error(`Error connecting to ${connector.title}:`, error);
|
||||
trackConnectorSetupFailure(
|
||||
Number(searchSpaceId),
|
||||
connector.connectorType,
|
||||
error instanceof Error ? error.message : "oauth_initiation_failed",
|
||||
"oauth_init"
|
||||
);
|
||||
if (error instanceof Error && error.message.includes("Invalid auth URL")) {
|
||||
toast.error(`Invalid response from ${connector.title} OAuth endpoint`);
|
||||
} else {
|
||||
|
|
@ -382,6 +406,11 @@ export const useConnectorDialog = () => {
|
|||
if (!searchSpaceId) return;
|
||||
|
||||
setConnectingId("webcrawler-connector");
|
||||
trackConnectorSetupStarted(
|
||||
Number(searchSpaceId),
|
||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
"webcrawler_quick_add"
|
||||
);
|
||||
try {
|
||||
await createConnector({
|
||||
data: {
|
||||
|
|
@ -431,6 +460,12 @@ export const useConnectorDialog = () => {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating webcrawler connector:", error);
|
||||
trackConnectorSetupFailure(
|
||||
Number(searchSpaceId),
|
||||
EnumConnectorName.WEBCRAWLER_CONNECTOR,
|
||||
error instanceof Error ? error.message : "webcrawler_create_failed",
|
||||
"webcrawler_quick_add"
|
||||
);
|
||||
toast.error("Failed to create web crawler connector");
|
||||
} finally {
|
||||
setConnectingId(null);
|
||||
|
|
@ -441,6 +476,13 @@ export const useConnectorDialog = () => {
|
|||
const handleConnectNonOAuth = useCallback(
|
||||
(connectorType: string) => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
trackConnectorSetupStarted(
|
||||
Number(searchSpaceId),
|
||||
connectorType,
|
||||
"non_oauth_click"
|
||||
);
|
||||
|
||||
setConnectingConnectorType(connectorType);
|
||||
},
|
||||
[searchSpaceId]
|
||||
|
|
@ -654,6 +696,12 @@ export const useConnectorDialog = () => {
|
|||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
trackConnectorSetupFailure(
|
||||
Number(searchSpaceId),
|
||||
connectingConnectorType ?? formData.connector_type,
|
||||
error instanceof Error ? error.message : "connector_create_failed",
|
||||
"non_oauth_form"
|
||||
);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
isCreatingConnectorRef.current = false;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -29,12 +29,16 @@ export function CreateFolderDialog({
|
|||
const [name, setName] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [open]);
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (next) {
|
||||
setName("");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
onOpenChange(next);
|
||||
},
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: React.FormEvent) => {
|
||||
|
|
@ -50,7 +54,7 @@ export function CreateFolderDialog({
|
|||
const isSubfolder = !!parentFolderName;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
|
||||
<DialogHeader className="space-y-2 pb-2">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, Folder, FolderOpen, Home } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -36,12 +36,16 @@ export function FolderPickerDialog({
|
|||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedId(null);
|
||||
setExpandedIds(new Set());
|
||||
}
|
||||
}, [open]);
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (next) {
|
||||
setSelectedId(null);
|
||||
setExpandedIds(new Set());
|
||||
}
|
||||
onOpenChange(next);
|
||||
},
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
const foldersByParent = useMemo(() => {
|
||||
const map: Record<string, FolderDisplay[]> = {};
|
||||
|
|
@ -123,7 +127,7 @@ export function FolderPickerDialog({
|
|||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="select-none max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
|
||||
<DialogHeader className="space-y-2 pb-2">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||
import type { AnonModel, AnonQuotaResponse } from "@/contracts/types/anonymous-chat.types";
|
||||
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
||||
import { readSSEStream } from "@/lib/chat/streaming-state";
|
||||
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { QuotaBar } from "./quota-bar";
|
||||
import { QuotaWarningBanner } from "./quota-warning-banner";
|
||||
|
|
@ -61,6 +62,12 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
|
|||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
|
||||
trackAnonymousChatMessageSent({
|
||||
modelSlug: model.seo_slug,
|
||||
messageLength: trimmed.length,
|
||||
surface: "free_model_page",
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
updateToolCall,
|
||||
} from "@/lib/chat/streaming-state";
|
||||
import { BACKEND_URL } from "@/lib/env-config";
|
||||
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
|
||||
import { FreeModelSelector } from "./free-model-selector";
|
||||
import { FreeThread } from "./free-thread";
|
||||
|
||||
|
|
@ -206,6 +207,14 @@ export function FreeChatPage() {
|
|||
}
|
||||
if (!userQuery.trim()) return;
|
||||
|
||||
trackAnonymousChatMessageSent({
|
||||
modelSlug,
|
||||
messageLength: userQuery.trim().length,
|
||||
hasUploadedDoc:
|
||||
anonMode.isAnonymous && anonMode.uploadedDoc !== null ? true : false,
|
||||
surface: "free_chat_page",
|
||||
});
|
||||
|
||||
const userMsgId = `msg-user-${Date.now()}`;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
|
|
|
|||
|
|
@ -27,13 +27,14 @@ export function FreeModelSelector({ className }: { className?: string }) {
|
|||
anonymousChatApiService.getModels().then(setModels).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const handleOpenChange = useCallback((next: boolean) => {
|
||||
if (next) {
|
||||
setSearchQuery("");
|
||||
setFocusedIndex(-1);
|
||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
}
|
||||
}, [open]);
|
||||
setOpen(next);
|
||||
}, []);
|
||||
|
||||
const currentModel = useMemo(
|
||||
() => models.find((m) => m.seo_slug === currentSlug) ?? null,
|
||||
|
|
@ -94,7 +95,7 @@ export function FreeModelSelector({ className }: { className?: string }) {
|
|||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import type { InboxItem } from "@/hooks/use-inbox";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
|
@ -25,9 +27,20 @@ import {
|
|||
Sidebar,
|
||||
} from "../sidebar";
|
||||
import { SidebarSlideOutPanel } from "../sidebar/SidebarSlideOutPanel";
|
||||
import { DocumentTabContent } from "../tabs/DocumentTabContent";
|
||||
import { TabBar } from "../tabs/TabBar";
|
||||
|
||||
const DocumentTabContent = dynamic(
|
||||
() => import("../tabs/DocumentTabContent").then((m) => ({ default: m.DocumentTabContent })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex-1 flex items-center justify-center h-full">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
// Per-tab data source
|
||||
interface TabDataSource {
|
||||
items: InboxItem[];
|
||||
|
|
|
|||
|
|
@ -478,7 +478,7 @@ function AuthenticatedDocumentsSidebar({
|
|||
setFolderPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const [, setIsExportingKB] = useState(false);
|
||||
const isExportingKBRef = useRef(false);
|
||||
const [exportWarningOpen, setExportWarningOpen] = useState(false);
|
||||
const [exportWarningContext, setExportWarningContext] = useState<{
|
||||
folder: FolderDisplay;
|
||||
|
|
@ -508,7 +508,7 @@ function AuthenticatedDocumentsSidebar({
|
|||
const ctx = exportWarningContext;
|
||||
if (!ctx?.folder) return;
|
||||
|
||||
setIsExportingKB(true);
|
||||
isExportingKBRef.current = true;
|
||||
try {
|
||||
const safeName =
|
||||
ctx.folder.name
|
||||
|
|
@ -524,7 +524,7 @@ function AuthenticatedDocumentsSidebar({
|
|||
console.error("Folder export failed:", err);
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
} finally {
|
||||
setIsExportingKB(false);
|
||||
isExportingKBRef.current = false;
|
||||
}
|
||||
setExportWarningContext(null);
|
||||
}, [exportWarningContext, searchSpaceId, doExport]);
|
||||
|
|
@ -560,7 +560,7 @@ function AuthenticatedDocumentsSidebar({
|
|||
return;
|
||||
}
|
||||
|
||||
setIsExportingKB(true);
|
||||
isExportingKBRef.current = true;
|
||||
try {
|
||||
const safeName =
|
||||
folder.name
|
||||
|
|
@ -576,7 +576,7 @@ function AuthenticatedDocumentsSidebar({
|
|||
console.error("Folder export failed:", err);
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
} finally {
|
||||
setIsExportingKB(false);
|
||||
isExportingKBRef.current = false;
|
||||
}
|
||||
},
|
||||
[searchSpaceId, getPendingCountInSubtree, doExport]
|
||||
|
|
|
|||
|
|
@ -269,6 +269,34 @@ export function ModelSelector({
|
|||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (next) {
|
||||
setSearchQuery("");
|
||||
setSelectedProvider("all");
|
||||
if (!isMobile) {
|
||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
}
|
||||
}
|
||||
setOpen(next);
|
||||
},
|
||||
[isMobile]
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(next: "llm" | "image" | "vision") => {
|
||||
setActiveTab(next);
|
||||
setSelectedProvider("all");
|
||||
setSearchQuery("");
|
||||
setFocusedIndex(-1);
|
||||
setModelScrollPos("top");
|
||||
if (open && !isMobile) {
|
||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
}
|
||||
},
|
||||
[open, isMobile]
|
||||
);
|
||||
|
||||
const handleModelListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
|
|
@ -292,43 +320,19 @@ export function ModelSelector({
|
|||
[isMobile]
|
||||
);
|
||||
|
||||
// Reset search + provider when tab changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger
|
||||
useEffect(() => {
|
||||
setSelectedProvider("all");
|
||||
setSearchQuery("");
|
||||
setFocusedIndex(-1);
|
||||
setModelScrollPos("top");
|
||||
}, [activeTab]);
|
||||
|
||||
// Reset on open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchQuery("");
|
||||
setSelectedProvider("all");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Cmd/Ctrl+M shortcut (desktop only)
|
||||
useEffect(() => {
|
||||
if (isMobile) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "m") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
// setOpen((prev) => !prev);
|
||||
handleOpenChange(!open);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [isMobile]);
|
||||
|
||||
// Focus search input on open
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeTab is intentionally used as a trigger to re-focus on tab switch
|
||||
useEffect(() => {
|
||||
if (open && !isMobile) {
|
||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
||||
}
|
||||
}, [open, isMobile, activeTab]);
|
||||
}, [isMobile, open, handleOpenChange]);
|
||||
|
||||
// ─── Data ───
|
||||
const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom);
|
||||
|
|
@ -971,7 +975,8 @@ export function ModelSelector({
|
|||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(value)}
|
||||
// onClick={() => setActiveTab(value)}
|
||||
onClick={() => handleTabChange(value)}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 text-sm font-medium transition-all duration-200 border-b-[1.5px]",
|
||||
activeTab === value
|
||||
|
|
@ -1208,7 +1213,7 @@ export function ModelSelector({
|
|||
// ─── Shell: Drawer on mobile, Popover on desktop ───
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerTrigger asChild>{triggerButton}</DrawerTrigger>
|
||||
<DrawerContent className="max-h-[85vh]">
|
||||
<DrawerHandle />
|
||||
|
|
@ -1222,7 +1227,7 @@ export function ModelSelector({
|
|||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[300px] md:w-[380px] p-0 rounded-lg shadow-lg overflow-hidden bg-white border-border/60 dark:bg-neutral-900 dark:border dark:border-white/5 select-none"
|
||||
|
|
|
|||
|
|
@ -586,7 +586,7 @@ export const useThemeToggle = ({
|
|||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setIsDark(!isDark);
|
||||
setIsDark((prev) => !prev);
|
||||
|
||||
const animation = createAnimation(variant, start, blur, gifUrl);
|
||||
|
||||
|
|
@ -604,7 +604,7 @@ export const useThemeToggle = ({
|
|||
}
|
||||
|
||||
document.startViewTransition(switchTheme);
|
||||
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles, isDark]);
|
||||
}, [theme, setTheme, variant, start, blur, gifUrl, updateStyles]);
|
||||
|
||||
const setCrazyLightTheme = useCallback(() => {
|
||||
setIsDark(false);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ export const tokenStripeStatusResponse = z.object({
|
|||
premium_tokens_remaining: z.number().default(0),
|
||||
});
|
||||
|
||||
export const tokenPurchaseStatusEnum = pagePurchaseStatusEnum;
|
||||
|
||||
export const tokenPurchase = z.object({
|
||||
id: z.uuid(),
|
||||
stripe_checkout_session_id: z.string(),
|
||||
|
|
@ -57,7 +59,7 @@ export const tokenPurchase = z.object({
|
|||
tokens_granted: z.number(),
|
||||
amount_total: z.number().nullable(),
|
||||
currency: z.string().nullable(),
|
||||
status: z.string(),
|
||||
status: tokenPurchaseStatusEnum,
|
||||
completed_at: z.string().nullable(),
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
|
@ -75,5 +77,6 @@ export type GetPagePurchasesResponse = z.infer<typeof getPagePurchasesResponse>;
|
|||
export type CreateTokenCheckoutSessionRequest = z.infer<typeof createTokenCheckoutSessionRequest>;
|
||||
export type CreateTokenCheckoutSessionResponse = z.infer<typeof createTokenCheckoutSessionResponse>;
|
||||
export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse>;
|
||||
export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>;
|
||||
export type TokenPurchase = z.infer<typeof tokenPurchase>;
|
||||
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,65 @@
|
|||
import posthog from "posthog-js";
|
||||
|
||||
function initPostHog() {
|
||||
/**
|
||||
* PostHog initialisation for the Next.js renderer.
|
||||
*
|
||||
* The same bundle ships in two contexts:
|
||||
* 1. A normal browser session on surfsense.com -> platform = "web"
|
||||
* 2. The Electron desktop app (renders the Next app from localhost)
|
||||
* -> platform = "desktop"
|
||||
*
|
||||
* When running inside Electron we also seed `posthog-js` with the main
|
||||
* process's machine distinctId so that events fired from both the renderer
|
||||
* (e.g. `chat_message_sent`, page views) and the Electron main process
|
||||
* (e.g. `desktop_quick_ask_opened`) share a single PostHog person before
|
||||
* login, and can be merged into the authenticated user afterwards.
|
||||
*/
|
||||
|
||||
function isElectron(): boolean {
|
||||
return typeof window !== "undefined" && !!window.electronAPI;
|
||||
}
|
||||
|
||||
function currentPlatform(): "desktop" | "web" {
|
||||
return isElectron() ? "desktop" : "web";
|
||||
}
|
||||
|
||||
async function resolveBootstrapDistinctId(): Promise<string | undefined> {
|
||||
if (!isElectron() || !window.electronAPI?.getAnalyticsContext) return undefined;
|
||||
try {
|
||||
const ctx = await window.electronAPI.getAnalyticsContext();
|
||||
return ctx?.machineId || ctx?.distinctId || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function initPostHog() {
|
||||
try {
|
||||
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return;
|
||||
|
||||
const platform = currentPlatform();
|
||||
const bootstrapDistinctId = await resolveBootstrapDistinctId();
|
||||
|
||||
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
api_host: "https://assets.surfsense.com",
|
||||
ui_host: "https://us.posthog.com",
|
||||
defaults: "2026-01-30",
|
||||
capture_pageview: "history_change",
|
||||
capture_pageleave: true,
|
||||
...(bootstrapDistinctId
|
||||
? {
|
||||
bootstrap: {
|
||||
distinctID: bootstrapDistinctId,
|
||||
isIdentifiedID: false,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
before_send: (event) => {
|
||||
if (event?.properties) {
|
||||
event.properties.platform = "web";
|
||||
event.properties.platform = platform;
|
||||
if (platform === "desktop") {
|
||||
event.properties.is_desktop = true;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ref = params.get("ref");
|
||||
|
|
@ -30,9 +77,14 @@ function initPostHog() {
|
|||
|
||||
event.properties.$set = {
|
||||
...event.properties.$set,
|
||||
platform: "web",
|
||||
platform,
|
||||
last_seen_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
event.properties.$set_once = {
|
||||
...event.properties.$set_once,
|
||||
first_seen_platform: platform,
|
||||
};
|
||||
}
|
||||
return event;
|
||||
},
|
||||
|
|
@ -51,8 +103,12 @@ if (typeof window !== "undefined") {
|
|||
window.posthog = posthog;
|
||||
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(initPostHog);
|
||||
requestIdleCallback(() => {
|
||||
void initPostHog();
|
||||
});
|
||||
} else {
|
||||
setTimeout(initPostHog, 3500);
|
||||
setTimeout(() => {
|
||||
void initPostHog();
|
||||
}, 3500);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import posthog from "posthog-js";
|
||||
import { getConnectorTelemetryMeta } from "@/components/assistant-ui/connector-popup/constants/connector-constants";
|
||||
|
||||
/**
|
||||
* PostHog Analytics Event Definitions
|
||||
|
|
@ -13,8 +14,8 @@ import posthog from "posthog-js";
|
|||
* - auth: Authentication events
|
||||
* - search_space: Search space management
|
||||
* - document: Document management
|
||||
* - chat: Chat and messaging
|
||||
* - connector: External connector events
|
||||
* - chat: Chat and messaging (authenticated + anonymous)
|
||||
* - connector: External connector events (all lifecycle stages)
|
||||
* - contact: Contact form events
|
||||
* - settings: Settings changes
|
||||
* - marketing: Marketing/referral tracking
|
||||
|
|
@ -28,6 +29,17 @@ function safeCapture(event: string, properties?: Record<string, unknown>) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop undefined values so PostHog doesn't log `"foo": undefined` noise.
|
||||
*/
|
||||
function compact<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (v !== undefined) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AUTH EVENTS
|
||||
// ============================================
|
||||
|
|
@ -127,6 +139,28 @@ export function trackChatError(searchSpaceId: number, chatId: number, error?: st
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a message sent from the unauthenticated "free" / anonymous chat
|
||||
* flow. This is intentionally a separate event from `chat_message_sent`
|
||||
* so WAU / retention queries on the authenticated event stay clean while
|
||||
* still giving us visibility into top-of-funnel usage on /free/*.
|
||||
*/
|
||||
export function trackAnonymousChatMessageSent(options: {
|
||||
modelSlug: string;
|
||||
messageLength?: number;
|
||||
hasUploadedDoc?: boolean;
|
||||
webSearchEnabled?: boolean;
|
||||
surface?: "free_chat_page" | "free_model_page";
|
||||
}) {
|
||||
safeCapture("anonymous_chat_message_sent", {
|
||||
model_slug: options.modelSlug,
|
||||
message_length: options.messageLength,
|
||||
has_uploaded_doc: options.hasUploadedDoc ?? false,
|
||||
web_search_enabled: options.webSearchEnabled,
|
||||
surface: options.surface,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DOCUMENT EVENTS
|
||||
// ============================================
|
||||
|
|
@ -179,37 +213,88 @@ export function trackYouTubeImport(searchSpaceId: number, url: string) {
|
|||
}
|
||||
|
||||
// ============================================
|
||||
// CONNECTOR EVENTS
|
||||
// CONNECTOR EVENTS (generic lifecycle dispatcher)
|
||||
// ============================================
|
||||
//
|
||||
// All connector events go through `trackConnectorEvent`. The connector's
|
||||
// human-readable title and its group (oauth/composio/crawler/other) are
|
||||
// auto-attached from the shared registry in `connector-constants.ts`, so
|
||||
// adding a new connector to that list is the only change required for it
|
||||
// to show up correctly in PostHog dashboards.
|
||||
|
||||
export function trackConnectorSetupStarted(searchSpaceId: number, connectorType: string) {
|
||||
safeCapture("connector_setup_started", {
|
||||
search_space_id: searchSpaceId,
|
||||
connector_type: connectorType,
|
||||
export type ConnectorEventStage =
|
||||
| "setup_started"
|
||||
| "setup_success"
|
||||
| "setup_failure"
|
||||
| "oauth_initiated"
|
||||
| "connected"
|
||||
| "deleted"
|
||||
| "synced";
|
||||
|
||||
export interface ConnectorEventOptions {
|
||||
searchSpaceId?: number | null;
|
||||
connectorId?: number | null;
|
||||
/** Source of the action (e.g. "oauth_callback", "non_oauth_form", "webcrawler_quick_add"). */
|
||||
source?: string;
|
||||
/** Free-form error message for failure events. */
|
||||
error?: string;
|
||||
/** Extra properties specific to the stage (e.g. frequency_minutes for sync events). */
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic connector lifecycle tracker. Every connector analytics event
|
||||
* should funnel through here so the enrichment stays consistent.
|
||||
*/
|
||||
export function trackConnectorEvent(
|
||||
stage: ConnectorEventStage,
|
||||
connectorType: string,
|
||||
options: ConnectorEventOptions = {}
|
||||
) {
|
||||
const meta = getConnectorTelemetryMeta(connectorType);
|
||||
safeCapture(`connector_${stage}`, {
|
||||
...compact({
|
||||
search_space_id: options.searchSpaceId ?? undefined,
|
||||
connector_id: options.connectorId ?? undefined,
|
||||
source: options.source,
|
||||
error: options.error,
|
||||
}),
|
||||
connector_type: meta.connector_type,
|
||||
connector_title: meta.connector_title,
|
||||
connector_group: meta.connector_group,
|
||||
is_oauth: meta.is_oauth,
|
||||
...(options.extra ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Convenience wrappers kept for backward compatibility ----
|
||||
|
||||
export function trackConnectorSetupStarted(
|
||||
searchSpaceId: number,
|
||||
connectorType: string,
|
||||
source?: string
|
||||
) {
|
||||
trackConnectorEvent("setup_started", connectorType, { searchSpaceId, source });
|
||||
}
|
||||
|
||||
export function trackConnectorSetupSuccess(
|
||||
searchSpaceId: number,
|
||||
connectorType: string,
|
||||
connectorId: number
|
||||
) {
|
||||
safeCapture("connector_setup_success", {
|
||||
search_space_id: searchSpaceId,
|
||||
connector_type: connectorType,
|
||||
connector_id: connectorId,
|
||||
});
|
||||
trackConnectorEvent("setup_success", connectorType, { searchSpaceId, connectorId });
|
||||
}
|
||||
|
||||
export function trackConnectorSetupFailure(
|
||||
searchSpaceId: number,
|
||||
searchSpaceId: number | null | undefined,
|
||||
connectorType: string,
|
||||
error?: string
|
||||
error?: string,
|
||||
source?: string
|
||||
) {
|
||||
safeCapture("connector_setup_failure", {
|
||||
search_space_id: searchSpaceId,
|
||||
connector_type: connectorType,
|
||||
trackConnectorEvent("setup_failure", connectorType, {
|
||||
searchSpaceId: searchSpaceId ?? undefined,
|
||||
error,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -218,11 +303,7 @@ export function trackConnectorDeleted(
|
|||
connectorType: string,
|
||||
connectorId: number
|
||||
) {
|
||||
safeCapture("connector_deleted", {
|
||||
search_space_id: searchSpaceId,
|
||||
connector_type: connectorType,
|
||||
connector_id: connectorId,
|
||||
});
|
||||
trackConnectorEvent("deleted", connectorType, { searchSpaceId, connectorId });
|
||||
}
|
||||
|
||||
export function trackConnectorSynced(
|
||||
|
|
@ -230,11 +311,7 @@ export function trackConnectorSynced(
|
|||
connectorType: string,
|
||||
connectorId: number
|
||||
) {
|
||||
safeCapture("connector_synced", {
|
||||
search_space_id: searchSpaceId,
|
||||
connector_type: connectorType,
|
||||
connector_id: connectorId,
|
||||
});
|
||||
trackConnectorEvent("synced", connectorType, { searchSpaceId, connectorId });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
|
@ -345,10 +422,9 @@ export function trackConnectorConnected(
|
|||
connectorType: string,
|
||||
connectorId?: number
|
||||
) {
|
||||
safeCapture("connector_connected", {
|
||||
search_space_id: searchSpaceId,
|
||||
connector_type: connectorType,
|
||||
connector_id: connectorId,
|
||||
trackConnectorEvent("connected", connectorType, {
|
||||
searchSpaceId,
|
||||
connectorId: connectorId ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -467,8 +543,13 @@ export function trackReferralLanding(refCode: string, landingUrl: string) {
|
|||
// ============================================
|
||||
|
||||
/**
|
||||
* Identify a user for PostHog analytics
|
||||
* Call this after successful authentication
|
||||
* Identify a user for PostHog analytics.
|
||||
* Call this after successful authentication.
|
||||
*
|
||||
* In the Electron desktop app the same call is mirrored into the
|
||||
* main-process PostHog client so desktop-only events (e.g.
|
||||
* `desktop_quick_ask_opened`, `desktop_autocomplete_accepted`) are
|
||||
* attributed to the logged-in user rather than an anonymous machine ID.
|
||||
*/
|
||||
export function identifyUser(userId: string, properties?: Record<string, unknown>) {
|
||||
try {
|
||||
|
|
@ -476,10 +557,19 @@ export function identifyUser(userId: string, properties?: Record<string, unknown
|
|||
} catch {
|
||||
// Silently ignore – ad-blockers may break posthog
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.electronAPI?.analyticsIdentify) {
|
||||
void window.electronAPI.analyticsIdentify(userId, properties);
|
||||
}
|
||||
} catch {
|
||||
// IPC errors must never break the app
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset user identity (call on logout)
|
||||
* Reset user identity (call on logout). Mirrors the reset into the
|
||||
* Electron main process when running inside the desktop app.
|
||||
*/
|
||||
export function resetUser() {
|
||||
try {
|
||||
|
|
@ -487,4 +577,12 @@ export function resetUser() {
|
|||
} catch {
|
||||
// Silently ignore – ad-blockers may break posthog
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window !== "undefined" && window.electronAPI?.analyticsReset) {
|
||||
void window.electronAPI.analyticsReset();
|
||||
}
|
||||
} catch {
|
||||
// IPC errors must never break the app
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
surfsense_web/types/window.d.ts
vendored
20
surfsense_web/types/window.d.ts
vendored
|
|
@ -102,9 +102,29 @@ interface ElectronAPI {
|
|||
setShortcuts: (
|
||||
config: Partial<{ generalAssist: string; quickAsk: string; autocomplete: string }>
|
||||
) => Promise<{ generalAssist: string; quickAsk: string; autocomplete: string }>;
|
||||
// Launch on system startup
|
||||
getAutoLaunch: () => Promise<{
|
||||
enabled: boolean;
|
||||
openAsHidden: boolean;
|
||||
supported: boolean;
|
||||
}>;
|
||||
setAutoLaunch: (
|
||||
enabled: boolean,
|
||||
openAsHidden?: boolean
|
||||
) => Promise<{ enabled: boolean; openAsHidden: boolean; supported: boolean }>;
|
||||
// Active search space
|
||||
getActiveSearchSpace: () => Promise<string | null>;
|
||||
setActiveSearchSpace: (id: string) => Promise<void>;
|
||||
// Analytics bridge (PostHog mirror into the Electron main process)
|
||||
analyticsIdentify: (userId: string, properties?: Record<string, unknown>) => Promise<void>;
|
||||
analyticsReset: () => Promise<void>;
|
||||
analyticsCapture: (event: string, properties?: Record<string, unknown>) => Promise<void>;
|
||||
getAnalyticsContext: () => Promise<{
|
||||
distinctId: string;
|
||||
machineId: string;
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue