mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-26 01:06:23 +02:00
Merge branch 'dev' of https://github.com/MODSetter/SurfSense into dev
This commit is contained in:
commit
8301e0169c
71 changed files with 2889 additions and 732 deletions
|
|
@ -240,6 +240,10 @@ export const ConnectorIndicator: FC = () => {
|
|||
...editingConnector,
|
||||
config: connectorConfig || editingConnector.config,
|
||||
name: editingConnector.name,
|
||||
// Sync last_indexed_at with live data from Electric SQL for real-time updates
|
||||
last_indexed_at:
|
||||
(connectors as SearchSourceConnector[]).find((c) => c.id === editingConnector.id)
|
||||
?.last_indexed_at ?? editingConnector.last_indexed_at,
|
||||
}}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateRangeSelectorProps {
|
||||
|
|
@ -26,19 +27,10 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
|||
allowFutureDates = false,
|
||||
lastIndexedAt,
|
||||
}) => {
|
||||
// Get the placeholder text for start date based on whether connector was previously indexed
|
||||
const getStartDatePlaceholder = () => {
|
||||
if (lastIndexedAt) {
|
||||
const date = new Date(lastIndexedAt);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const indexedYear = date.getFullYear();
|
||||
// Show year only if different from current year
|
||||
const formatStr = indexedYear === currentYear ? "MMM d, HH:mm" : "MMM d, yyyy HH:mm";
|
||||
const formattedDate = format(date, formatStr);
|
||||
return `Since (${formattedDate})`;
|
||||
}
|
||||
return "Default (1 year ago)";
|
||||
};
|
||||
const startDatePlaceholder = lastIndexedAt
|
||||
? `From ${formatRelativeDate(lastIndexedAt)}`
|
||||
: "Default (1 year)";
|
||||
|
||||
const handleLast30Days = () => {
|
||||
const today = new Date();
|
||||
onStartDateChange(subDays(today, 30));
|
||||
|
|
@ -88,7 +80,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
|||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{startDate ? format(startDate, "PPP") : getStartDatePlaceholder()}
|
||||
{startDate ? format(startDate, "PPP") : startDatePlaceholder}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0 z-[100]" align="start">
|
||||
|
|
|
|||
|
|
@ -1,29 +1,188 @@
|
|||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { AlertCircle, CheckCircle2, Hash, Info, Megaphone, RefreshCw } from "lucide-react";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { connectorsApiService, type DiscordChannel } from "@/lib/apis/connectors-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface DiscordConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const DiscordConfig: FC<DiscordConfigProps> = () => {
|
||||
export const DiscordConfig: FC<DiscordConfigProps> = ({ connector }) => {
|
||||
const [channels, setChannels] = useState<DiscordChannel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastFetched, setLastFetched] = useState<Date | null>(null);
|
||||
|
||||
const fetchChannels = useCallback(async () => {
|
||||
if (!connector?.id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await connectorsApiService.getDiscordChannels(connector.id);
|
||||
setChannels(data);
|
||||
setLastFetched(new Date());
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch Discord channels:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch channels");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [connector?.id]);
|
||||
|
||||
// Fetch channels on mount
|
||||
useEffect(() => {
|
||||
fetchChannels();
|
||||
}, [fetchChannels]);
|
||||
|
||||
// Auto-refresh when user returns to tab
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible" && connector?.id) {
|
||||
fetchChannels();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
}, [connector?.id, fetchChannels]);
|
||||
|
||||
// Separate channels by indexing capability
|
||||
const readyToIndex = channels.filter((ch) => ch.can_index);
|
||||
const needsPermissions = channels.filter((ch) => !ch.can_index);
|
||||
|
||||
// Format last fetched time
|
||||
const formatLastFetched = () => {
|
||||
if (!lastFetched) return null;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - lastFetched.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
|
||||
if (diffSecs < 60) return "just now";
|
||||
if (diffMins === 1) return "1 minute ago";
|
||||
if (diffMins < 60) return `${diffMins} minutes ago`;
|
||||
return lastFetched.toLocaleTimeString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Info box */}
|
||||
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
|
||||
<Info className="size-4" />
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm">
|
||||
<p className="font-medium text-xs sm:text-sm">Add Bot to Servers</p>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
||||
Before indexing, make sure the Discord bot has been added to the servers (guilds) you
|
||||
want to index. The bot can only access messages from servers it's been added to. Use the
|
||||
OAuth authorization flow to add the bot to your servers.
|
||||
The bot needs "Read Message History" permission to index channels. Ask a
|
||||
server admin to grant this permission for channels shown below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channels Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-semibold">Channel Access</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{lastFetched && (
|
||||
<span className="text-[10px] text-muted-foreground">{formatLastFetched()}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={fetchChannels}
|
||||
disabled={isLoading}
|
||||
className="h-7 px-2.5 text-[11px] bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20"
|
||||
>
|
||||
<RefreshCw className={cn("mr-1.5 size-3", isLoading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && channels.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="sm" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Loading channels</span>
|
||||
</div>
|
||||
) : channels.length === 0 && !error ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
No channels found. Make sure the bot has been added to your Discord server with proper
|
||||
permissions.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 overflow-hidden">
|
||||
{/* Ready to index */}
|
||||
{readyToIndex.length > 0 && (
|
||||
<div className={cn("p-3", needsPermissions.length > 0 && "border-b border-border")}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle2 className="size-3.5 text-emerald-500" />
|
||||
<span className="text-[11px] font-medium">Ready to index</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{readyToIndex.length} {readyToIndex.length === 1 ? "channel" : "channels"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{readyToIndex.map((channel) => (
|
||||
<ChannelPill key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Needs permissions */}
|
||||
{needsPermissions.length > 0 && (
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="size-3.5 text-amber-500" />
|
||||
<span className="text-[11px] font-medium">Grant permissions to index</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{needsPermissions.length}{" "}
|
||||
{needsPermissions.length === 1 ? "channel" : "channels"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{needsPermissions.map((channel) => (
|
||||
<ChannelPill key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChannelPillProps {
|
||||
channel: DiscordChannel;
|
||||
}
|
||||
|
||||
const ChannelPill: FC<ChannelPillProps> = ({ channel }) => {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 transition-colors">
|
||||
{channel.type === "announcement" ? (
|
||||
<Megaphone className="size-2.5 text-muted-foreground" />
|
||||
) : (
|
||||
<Hash className="size-2.5 text-muted-foreground" />
|
||||
)}
|
||||
<span>{channel.name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,16 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { AlertCircle, CheckCircle2, Hash, Info, Lock, RefreshCw } from "lucide-react";
|
||||
import { type FC, useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { connectorsApiService, type SlackChannel } from "@/lib/apis/connectors-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface SlackConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const SlackConfig: FC<SlackConfigProps> = () => {
|
||||
export const SlackConfig: FC<SlackConfigProps> = ({ connector }) => {
|
||||
const [channels, setChannels] = useState<SlackChannel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastFetched, setLastFetched] = useState<Date | null>(null);
|
||||
|
||||
const fetchChannels = useCallback(async () => {
|
||||
if (!connector?.id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await connectorsApiService.getSlackChannels(connector.id);
|
||||
setChannels(data);
|
||||
setLastFetched(new Date());
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch Slack channels:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch channels");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [connector?.id]);
|
||||
|
||||
// Fetch channels on mount
|
||||
useEffect(() => {
|
||||
fetchChannels();
|
||||
}, [fetchChannels]);
|
||||
|
||||
// Auto-refresh when user returns to tab
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible" && connector?.id) {
|
||||
fetchChannels();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
}, [connector?.id, fetchChannels]);
|
||||
|
||||
// Separate channels by bot membership
|
||||
const channelsWithBot = channels.filter((ch) => ch.is_member);
|
||||
const channelsWithoutBot = channels.filter((ch) => !ch.is_member);
|
||||
|
||||
// Format last fetched time
|
||||
const formatLastFetched = () => {
|
||||
if (!lastFetched) return null;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - lastFetched.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
|
||||
if (diffSecs < 60) return "just now";
|
||||
if (diffMins === 1) return "1 minute ago";
|
||||
if (diffMins < 60) return `${diffMins} minutes ago`;
|
||||
return lastFetched.toLocaleTimeString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Info box */}
|
||||
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
|
||||
<Info className="size-4" />
|
||||
|
|
@ -25,6 +88,103 @@ export const SlackConfig: FC<SlackConfigProps> = () => {
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channels Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-semibold">Channel Access</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{lastFetched && (
|
||||
<span className="text-[10px] text-muted-foreground">{formatLastFetched()}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={fetchChannels}
|
||||
disabled={isLoading}
|
||||
className="h-7 px-2.5 text-[11px] bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20"
|
||||
>
|
||||
<RefreshCw className={cn("mr-1.5 size-3", isLoading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && channels.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner size="sm" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">Loading channels</span>
|
||||
</div>
|
||||
) : channels.length === 0 && !error ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
No channels found. Make sure the bot has been added to your Slack workspace.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 overflow-hidden">
|
||||
{/* Channels with bot access */}
|
||||
{channelsWithBot.length > 0 && (
|
||||
<div className={cn("p-3", channelsWithoutBot.length > 0 && "border-b border-border")}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle2 className="size-3.5 text-emerald-500" />
|
||||
<span className="text-[11px] font-medium">Ready to index</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{channelsWithBot.length} {channelsWithBot.length === 1 ? "channel" : "channels"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{channelsWithBot.map((channel) => (
|
||||
<ChannelPill key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channels without bot access */}
|
||||
{channelsWithoutBot.length > 0 && (
|
||||
<div className="p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="size-3.5 text-amber-500" />
|
||||
<span className="text-[11px] font-medium">Add bot to index</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{channelsWithoutBot.length}{" "}
|
||||
{channelsWithoutBot.length === 1 ? "channel" : "channels"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{channelsWithoutBot.map((channel) => (
|
||||
<ChannelPill key={channel.id} channel={channel} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChannelPillProps {
|
||||
channel: SlackChannel;
|
||||
}
|
||||
|
||||
const ChannelPill: FC<ChannelPillProps> = ({ channel }) => {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 transition-colors">
|
||||
{channel.is_private ? (
|
||||
<Lock className="size-2.5 text-muted-foreground" />
|
||||
) : (
|
||||
<Hash className="size-2.5 text-muted-foreground" />
|
||||
)}
|
||||
<span>{channel.name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,44 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
|||
import type { InboxItem } from "@/contracts/types/inbox.types";
|
||||
import { isConnectorIndexingMetadata } from "@/contracts/types/inbox.types";
|
||||
|
||||
/**
|
||||
* Timeout thresholds for stuck task detection
|
||||
*
|
||||
* These align with the backend Celery configuration:
|
||||
* - HARD_TIMEOUT: 8 hours (task_time_limit=28800 in Celery)
|
||||
* Any task running longer than this is definitely dead.
|
||||
*
|
||||
* - STALE_THRESHOLD: 15 minutes without notification updates
|
||||
* If heartbeats are being sent every 30s, missing 15+ minutes of updates
|
||||
* indicates the task has likely crashed or the worker is down.
|
||||
*/
|
||||
const HARD_TIMEOUT_MS = 8 * 60 * 60 * 1000; // 8 hours in milliseconds
|
||||
const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes in milliseconds
|
||||
|
||||
/**
|
||||
* Check if a notification is stale (no updates for too long)
|
||||
* @param updatedAt - ISO timestamp of last notification update
|
||||
* @returns true if the notification hasn't been updated recently
|
||||
*/
|
||||
function isNotificationStale(updatedAt: string | null | undefined): boolean {
|
||||
if (!updatedAt) return false;
|
||||
const lastUpdate = new Date(updatedAt).getTime();
|
||||
const now = Date.now();
|
||||
return now - lastUpdate > STALE_THRESHOLD_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task has exceeded the hard timeout (definitely dead)
|
||||
* @param startedAt - ISO timestamp when the task started
|
||||
* @returns true if the task has been running longer than the hard limit
|
||||
*/
|
||||
function isTaskTimedOut(startedAt: string | null | undefined): boolean {
|
||||
if (!startedAt) return false;
|
||||
const startTime = new Date(startedAt).getTime();
|
||||
const now = Date.now();
|
||||
return now - startTime > HARD_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to track which connectors are currently indexing using local state.
|
||||
*
|
||||
|
|
@ -13,6 +51,8 @@ import { isConnectorIndexingMetadata } from "@/contracts/types/inbox.types";
|
|||
* 2. Detecting in_progress notifications from Electric SQL to restore state after remounts
|
||||
* 3. Clearing indexing state when notifications become completed or failed
|
||||
* 4. Clearing indexing state when Electric SQL detects last_indexed_at changed
|
||||
* 5. Detecting stale/stuck tasks that haven't updated in 15+ minutes
|
||||
* 6. Detecting hard timeout (8h) - tasks that definitely cannot still be running
|
||||
*
|
||||
* The actual `last_indexed_at` value comes from Electric SQL/PGlite, not local state.
|
||||
*/
|
||||
|
|
@ -57,6 +97,7 @@ export function useIndexingConnectors(
|
|||
|
||||
// Detect notification status changes and update indexing state accordingly
|
||||
// This restores spinner state after component remounts and handles all status transitions
|
||||
// Also detects stale/stuck tasks that haven't been updated in a while
|
||||
useEffect(() => {
|
||||
if (!inboxItems || inboxItems.length === 0) return;
|
||||
|
||||
|
|
@ -71,11 +112,26 @@ export function useIndexingConnectors(
|
|||
const metadata = isConnectorIndexingMetadata(item.metadata) ? item.metadata : null;
|
||||
if (!metadata) continue;
|
||||
|
||||
// If status is "in_progress", add connector to indexing set
|
||||
// If status is "in_progress", check if it's actually still running
|
||||
if (metadata.status === "in_progress") {
|
||||
if (!newIndexingIds.has(metadata.connector_id)) {
|
||||
newIndexingIds.add(metadata.connector_id);
|
||||
hasChanges = true;
|
||||
// Check for hard timeout (8h) - task is definitely dead
|
||||
const timedOut = isTaskTimedOut(metadata.started_at);
|
||||
|
||||
// Check for stale notification (15min without updates) - task likely crashed
|
||||
const stale = isNotificationStale(item.updated_at);
|
||||
|
||||
if (timedOut || stale) {
|
||||
// Task is stuck - don't show as indexing
|
||||
if (newIndexingIds.has(metadata.connector_id)) {
|
||||
newIndexingIds.delete(metadata.connector_id);
|
||||
hasChanges = true;
|
||||
}
|
||||
} else {
|
||||
// Task appears to be genuinely running
|
||||
if (!newIndexingIds.has(metadata.connector_id)) {
|
||||
newIndexingIds.add(metadata.connector_id);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If status is "completed" or "failed", remove connector from indexing set
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
|
||||
import { ArrowLeft, Plus, Server } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -8,6 +7,7 @@ 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 { formatRelativeDate } from "@/lib/format-date";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectorStatus } from "../hooks/use-connector-status";
|
||||
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
|
||||
|
|
@ -32,38 +32,6 @@ function isIndexableConnector(connectorType: string): boolean {
|
|||
return !nonIndexableTypes.includes(connectorType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format last indexed date with contextual messages
|
||||
*/
|
||||
function formatLastIndexedDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const minutesAgo = differenceInMinutes(now, date);
|
||||
const daysAgo = differenceInDays(now, date);
|
||||
|
||||
if (minutesAgo < 1) {
|
||||
return "Just now";
|
||||
}
|
||||
|
||||
if (minutesAgo < 60) {
|
||||
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
|
||||
}
|
||||
|
||||
if (isToday(date)) {
|
||||
return `Today at ${format(date, "h:mm a")}`;
|
||||
}
|
||||
|
||||
if (isYesterday(date)) {
|
||||
return `Yesterday at ${format(date, "h:mm a")}`;
|
||||
}
|
||||
|
||||
if (daysAgo < 7) {
|
||||
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
|
||||
}
|
||||
|
||||
return format(date, "MMM d, yyyy");
|
||||
}
|
||||
|
||||
export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
||||
connectorType,
|
||||
connectorTitle,
|
||||
|
|
@ -215,7 +183,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
<p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap truncate">
|
||||
{isIndexableConnector(connector.connector_type)
|
||||
? connector.last_indexed_at
|
||||
? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
|
||||
? `Last indexed: ${formatRelativeDate(connector.last_indexed_at)}`
|
||||
: "Never indexed"
|
||||
: "Active"}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { FolderOpen, MessageSquare, PenSquare } from "lucide-react";
|
||||
import { FolderOpen, PenSquare } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -122,33 +122,15 @@ export function Sidebar({
|
|||
|
||||
{/* Chat sections - fills available space */}
|
||||
{isCollapsed ? (
|
||||
<div className="flex-1 flex flex-col items-center gap-2 py-2 w-[60px]">
|
||||
{(chats.length > 0 || sharedChats.length > 0) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onToggleCollapse?.()}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<span className="sr-only">{t("chats")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t("chats")} ({chats.length + sharedChats.length})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 w-[60px]" />
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col gap-1 py-2 w-[240px] min-h-0 overflow-hidden">
|
||||
{/* Shared Chats Section - takes half the space */}
|
||||
{/* Shared Chats Section - takes only space needed, max 50% */}
|
||||
<SidebarSection
|
||||
title={t("shared_chats")}
|
||||
defaultOpen={true}
|
||||
fillHeight={true}
|
||||
fillHeight={false}
|
||||
className="shrink-0 max-h-[50%] flex flex-col"
|
||||
action={
|
||||
onViewAllSharedChats ? (
|
||||
<Tooltip>
|
||||
|
|
@ -170,9 +152,9 @@ export function Sidebar({
|
|||
}
|
||||
>
|
||||
{sharedChats.length > 0 ? (
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<div
|
||||
className={`flex flex-col gap-0.5 h-full overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent ${sharedChats.length > 4 ? "pb-8" : ""}`}
|
||||
className={`flex flex-col gap-0.5 max-h-full overflow-y-auto scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent ${sharedChats.length > 4 ? "pb-8" : ""}`}
|
||||
>
|
||||
{sharedChats.slice(0, 20).map((chat) => (
|
||||
<ChatListItem
|
||||
|
|
@ -196,7 +178,7 @@ export function Sidebar({
|
|||
)}
|
||||
</SidebarSection>
|
||||
|
||||
{/* Private Chats Section - takes half the space */}
|
||||
{/* Private Chats Section - fills remaining space */}
|
||||
<SidebarSection
|
||||
title={t("chats")}
|
||||
defaultOpen={true}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,12 @@ export function SidebarSection({
|
|||
<Collapsible
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
className={cn("overflow-hidden", fillHeight && "flex flex-col flex-1 min-h-0", className)}
|
||||
className={cn(
|
||||
"overflow-hidden",
|
||||
fillHeight && "flex flex-col min-h-0",
|
||||
fillHeight && isOpen && "flex-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center group/section shrink-0">
|
||||
<CollapsibleTrigger className="flex flex-1 items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors min-w-0">
|
||||
|
|
@ -56,12 +61,8 @@ export function SidebarSection({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<CollapsibleContent
|
||||
className={cn("overflow-hidden", fillHeight && "flex-1 flex flex-col min-h-0")}
|
||||
>
|
||||
<div
|
||||
className={cn("px-2 pb-2", fillHeight && "flex-1 flex flex-col min-h-0 overflow-hidden")}
|
||||
>
|
||||
<CollapsibleContent className={cn("overflow-hidden flex-1 flex flex-col min-h-0")}>
|
||||
<div className={cn("px-2 pb-2 flex-1 flex flex-col min-h-0 overflow-hidden")}>
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Globe, Link2, User, Users } from "lucide-react";
|
||||
import { Globe, User, Users } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { togglePublicShareMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms";
|
||||
import { createSnapshotMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms";
|
||||
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -49,19 +49,15 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
|
||||
// Use Jotai atom for visibility (single source of truth)
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
const setCurrentThreadState = useSetAtom(currentThreadAtom);
|
||||
const setThreadVisibility = useSetAtom(setThreadVisibilityAtom);
|
||||
|
||||
// Public share mutation
|
||||
const { mutateAsync: togglePublicShare, isPending: isTogglingPublic } = useAtomValue(
|
||||
togglePublicShareMutationAtom
|
||||
// Snapshot creation mutation
|
||||
const { mutateAsync: createSnapshot, isPending: isCreatingSnapshot } = useAtomValue(
|
||||
createSnapshotMutationAtom
|
||||
);
|
||||
|
||||
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
||||
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
||||
const isPublicEnabled =
|
||||
currentThreadState.publicShareEnabled ?? thread?.public_share_enabled ?? false;
|
||||
const publicShareToken = currentThreadState.publicShareToken ?? null;
|
||||
|
||||
const handleVisibilityChange = useCallback(
|
||||
async (newVisibility: ChatVisibility) => {
|
||||
|
|
@ -96,45 +92,24 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
|
||||
);
|
||||
|
||||
const handlePublicShareToggle = useCallback(async () => {
|
||||
const handleCreatePublicLink = useCallback(async () => {
|
||||
if (!thread) return;
|
||||
|
||||
try {
|
||||
const response = await togglePublicShare({
|
||||
thread_id: thread.id,
|
||||
enabled: !isPublicEnabled,
|
||||
});
|
||||
|
||||
// Update atom state with response
|
||||
setCurrentThreadState((prev) => ({
|
||||
...prev,
|
||||
publicShareEnabled: response.enabled,
|
||||
publicShareToken: response.share_token,
|
||||
}));
|
||||
await createSnapshot({ thread_id: thread.id });
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle public share:", error);
|
||||
console.error("Failed to create public link:", error);
|
||||
}
|
||||
}, [thread, isPublicEnabled, togglePublicShare, setCurrentThreadState]);
|
||||
|
||||
const handleCopyPublicLink = useCallback(async () => {
|
||||
if (!publicShareToken) return;
|
||||
|
||||
const publicUrl = `${window.location.origin}/public/${publicShareToken}`;
|
||||
await navigator.clipboard.writeText(publicUrl);
|
||||
toast.success("Public link copied to clipboard");
|
||||
}, [publicShareToken]);
|
||||
}, [thread, createSnapshot]);
|
||||
|
||||
// Don't show if no thread (new chat that hasn't been created yet)
|
||||
if (!thread) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const CurrentIcon = isPublicEnabled ? Globe : currentVisibility === "PRIVATE" ? User : Users;
|
||||
const buttonLabel = isPublicEnabled
|
||||
? "Public"
|
||||
: currentVisibility === "PRIVATE"
|
||||
? "Private"
|
||||
: "Shared";
|
||||
const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users;
|
||||
const buttonLabel = currentVisibility === "PRIVATE" ? "Private" : "Shared";
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -211,67 +186,31 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
{/* Divider */}
|
||||
<div className="border-t border-border my-1" />
|
||||
|
||||
{/* Public Share Option */}
|
||||
{/* Public Link Option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePublicShareToggle}
|
||||
disabled={isTogglingPublic}
|
||||
onClick={handleCreatePublicLink}
|
||||
disabled={isCreatingSnapshot}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||
"hover:bg-accent/50 cursor-pointer",
|
||||
"focus:outline-none",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
isPublicEnabled && "bg-accent/80"
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"size-7 rounded-md shrink-0 grid place-items-center",
|
||||
isPublicEnabled ? "bg-primary/10" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
<Globe
|
||||
className={cn(
|
||||
"size-4 block",
|
||||
isPublicEnabled ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
/>
|
||||
<div className="size-7 rounded-md shrink-0 grid place-items-center bg-muted">
|
||||
<Globe className="size-4 block text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("text-sm font-medium", isPublicEnabled && "text-primary")}>
|
||||
Public
|
||||
<span className="text-sm font-medium">
|
||||
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
|
||||
</span>
|
||||
{isPublicEnabled && (
|
||||
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded">
|
||||
ON
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||
Anyone with the link can read
|
||||
Creates a shareable snapshot of this chat
|
||||
</p>
|
||||
</div>
|
||||
{isPublicEnabled && publicShareToken && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyPublicLink();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
handleCopyPublicLink();
|
||||
}
|
||||
}}
|
||||
className="shrink-0 p-1.5 rounded-md hover:bg-muted transition-colors cursor-pointer"
|
||||
title="Copy public link"
|
||||
>
|
||||
<Link2 className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
|
|||
share_token: shareToken,
|
||||
});
|
||||
|
||||
// Redirect to the new chat page (content will be loaded there)
|
||||
// Redirect to the new chat page with cloned content
|
||||
router.push(`/dashboard/${response.search_space_id}/new-chat/${response.thread_id}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to copy chat";
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { AlertCircleIcon, MicIcon } from "lucide-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Audio } from "@/components/tool-ui/audio";
|
||||
|
|
@ -172,9 +173,6 @@ function AudioLoadingState({ title }: { title: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Podcast Player Component - Fetches audio and transcript with authentication
|
||||
*/
|
||||
function PodcastPlayer({
|
||||
podcastId,
|
||||
title,
|
||||
|
|
@ -186,6 +184,11 @@ function PodcastPlayer({
|
|||
description: string;
|
||||
durationMs?: number;
|
||||
}) {
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const isPublicRoute = pathname?.startsWith("/public/");
|
||||
const shareToken = isPublicRoute && typeof params?.token === "string" ? params.token : null;
|
||||
|
||||
const [audioSrc, setAudioSrc] = useState<string | null>(null);
|
||||
const [transcript, setTranscript] = useState<PodcastTranscriptEntry[] | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -217,30 +220,46 @@ function PodcastPlayer({
|
|||
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60s timeout
|
||||
|
||||
try {
|
||||
// Fetch audio blob and podcast details in parallel
|
||||
const [audioResponse, rawPodcastDetails] = await Promise.all([
|
||||
authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
|
||||
{ method: "GET", signal: controller.signal }
|
||||
),
|
||||
baseApiService.get<unknown>(`/api/v1/podcasts/${podcastId}`),
|
||||
]);
|
||||
let audioBlob: Blob;
|
||||
let rawPodcastDetails: unknown = null;
|
||||
|
||||
if (!audioResponse.ok) {
|
||||
throw new Error(`Failed to load audio: ${audioResponse.status}`);
|
||||
if (shareToken) {
|
||||
// Public view - use public endpoints (baseApiService handles no-auth for /api/v1/public/)
|
||||
const [blob, details] = await Promise.all([
|
||||
baseApiService.getBlob(`/api/v1/public/${shareToken}/podcasts/${podcastId}/stream`),
|
||||
baseApiService.get(`/api/v1/public/${shareToken}/podcasts/${podcastId}`),
|
||||
]);
|
||||
audioBlob = blob;
|
||||
rawPodcastDetails = details;
|
||||
} else {
|
||||
// Authenticated view - fetch audio and details in parallel
|
||||
const [audioResponse, details] = await Promise.all([
|
||||
authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/podcasts/${podcastId}/audio`,
|
||||
{ method: "GET", signal: controller.signal }
|
||||
),
|
||||
baseApiService.get<unknown>(`/api/v1/podcasts/${podcastId}`),
|
||||
]);
|
||||
|
||||
if (!audioResponse.ok) {
|
||||
throw new Error(`Failed to load audio: ${audioResponse.status}`);
|
||||
}
|
||||
|
||||
audioBlob = await audioResponse.blob();
|
||||
rawPodcastDetails = details;
|
||||
}
|
||||
|
||||
const audioBlob = await audioResponse.blob();
|
||||
|
||||
// Create object URL from blob
|
||||
const objectUrl = URL.createObjectURL(audioBlob);
|
||||
objectUrlRef.current = objectUrl;
|
||||
setAudioSrc(objectUrl);
|
||||
|
||||
// Parse and validate podcast details, then set transcript
|
||||
const podcastDetails = parsePodcastDetails(rawPodcastDetails);
|
||||
if (podcastDetails.podcast_transcript) {
|
||||
setTranscript(podcastDetails.podcast_transcript);
|
||||
if (rawPodcastDetails) {
|
||||
const podcastDetails = parsePodcastDetails(rawPodcastDetails);
|
||||
if (podcastDetails.podcast_transcript) {
|
||||
setTranscript(podcastDetails.podcast_transcript);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
|
|
@ -255,7 +274,7 @@ function PodcastPlayer({
|
|||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [podcastId]);
|
||||
}, [podcastId, shareToken]);
|
||||
|
||||
// Load podcast when component mounts
|
||||
useEffect(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue