mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 10:26:33 +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
|
|
@ -42,7 +42,6 @@ import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
||||
import { useMessagesElectric } from "@/hooks/use-messages-electric";
|
||||
import { publicChatApiService } from "@/lib/apis/public-chat-api.service";
|
||||
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
|
||||
|
|
@ -142,8 +141,6 @@ export default function NewChatPage() {
|
|||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [isCompletingClone, setIsCompletingClone] = useState(false);
|
||||
const [cloneError, setCloneError] = useState(false);
|
||||
const [threadId, setThreadId] = useState<number | null>(null);
|
||||
const [currentThread, setCurrentThread] = useState<ThreadRecord | null>(null);
|
||||
const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
|
||||
|
|
@ -332,42 +329,6 @@ export default function NewChatPage() {
|
|||
initializeThread();
|
||||
}, [initializeThread]);
|
||||
|
||||
// Handle clone completion when thread has clone_pending flag
|
||||
useEffect(() => {
|
||||
if (!currentThread?.clone_pending || isCompletingClone || cloneError) return;
|
||||
|
||||
const completeClone = async () => {
|
||||
setIsCompletingClone(true);
|
||||
|
||||
try {
|
||||
await publicChatApiService.completeClone({ thread_id: currentThread.id });
|
||||
|
||||
// Re-initialize thread to fetch cloned content using existing logic
|
||||
await initializeThread();
|
||||
|
||||
// Invalidate threads query to update sidebar
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[NewChatPage] Failed to complete clone:", error);
|
||||
toast.error("Failed to copy chat content. Please try again.");
|
||||
setCloneError(true);
|
||||
} finally {
|
||||
setIsCompletingClone(false);
|
||||
}
|
||||
};
|
||||
|
||||
completeClone();
|
||||
}, [
|
||||
currentThread?.clone_pending,
|
||||
currentThread?.id,
|
||||
isCompletingClone,
|
||||
cloneError,
|
||||
initializeThread,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
// Handle scroll to comment from URL query params (e.g., from inbox item click)
|
||||
const searchParams = useSearchParams();
|
||||
const targetCommentIdParam = searchParams.get("commentId");
|
||||
|
|
@ -394,8 +355,6 @@ export default function NewChatPage() {
|
|||
visibility: currentThread?.visibility ?? null,
|
||||
hasComments: currentThread?.has_comments ?? false,
|
||||
addingCommentToMessageId: null,
|
||||
publicShareEnabled: currentThread?.public_share_enabled ?? false,
|
||||
publicShareToken: currentThread?.public_share_token ?? null,
|
||||
}));
|
||||
}, [currentThread, setCurrentThreadState]);
|
||||
|
||||
|
|
@ -1420,16 +1379,6 @@ export default function NewChatPage() {
|
|||
);
|
||||
}
|
||||
|
||||
// Show loading state while completing clone
|
||||
if (isCompletingClone) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
|
||||
<Spinner size="lg" />
|
||||
<div className="text-sm text-muted-foreground">Copying chat content...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state only if we tried to load an existing thread but failed
|
||||
// For new chats (urlChatId === 0), threadId being null is expected (lazy creation)
|
||||
if (!threadId && urlChatId > 0) {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,31 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
TogglePublicShareRequest,
|
||||
TogglePublicShareResponse,
|
||||
CreateSnapshotRequest,
|
||||
CreateSnapshotResponse,
|
||||
} from "@/contracts/types/chat-threads.types";
|
||||
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
|
||||
|
||||
export const togglePublicShareMutationAtom = atomWithMutation(() => ({
|
||||
mutationFn: async (request: TogglePublicShareRequest) => {
|
||||
return chatThreadsApiService.togglePublicShare(request);
|
||||
export const createSnapshotMutationAtom = atomWithMutation(() => ({
|
||||
mutationFn: async (request: CreateSnapshotRequest) => {
|
||||
return chatThreadsApiService.createSnapshot(request);
|
||||
},
|
||||
onSuccess: (response: TogglePublicShareResponse) => {
|
||||
if (response.enabled && response.share_token) {
|
||||
const publicUrl = `${window.location.origin}/public/${response.share_token}`;
|
||||
navigator.clipboard.writeText(publicUrl);
|
||||
toast.success("Public link copied to clipboard", {
|
||||
description: "Anyone with this link can view the chat",
|
||||
onSuccess: (response: CreateSnapshotResponse) => {
|
||||
// Construct URL using frontend origin (backend returns its own URL which differs)
|
||||
const publicUrl = `${window.location.origin}/public/${response.share_token}`;
|
||||
navigator.clipboard.writeText(publicUrl);
|
||||
if (response.is_new) {
|
||||
toast.success("Public link created and copied to clipboard", {
|
||||
description: "Anyone with this link can view a snapshot of this chat",
|
||||
});
|
||||
} else {
|
||||
toast.success("Public sharing disabled");
|
||||
toast.success("Public link copied to clipboard", {
|
||||
description: "This snapshot already exists",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Failed to toggle public share:", error);
|
||||
toast.error("Failed to update public sharing");
|
||||
console.error("Failed to create snapshot:", error);
|
||||
toast.error("Failed to create public link");
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ interface CurrentThreadState {
|
|||
addingCommentToMessageId: number | null;
|
||||
/** Whether the right-side comments panel is collapsed (desktop only) */
|
||||
commentsCollapsed: boolean;
|
||||
publicShareEnabled: boolean;
|
||||
publicShareToken: string | null;
|
||||
}
|
||||
|
||||
const initialState: CurrentThreadState = {
|
||||
|
|
@ -29,8 +27,6 @@ const initialState: CurrentThreadState = {
|
|||
hasComments: false,
|
||||
addingCommentToMessageId: null,
|
||||
commentsCollapsed: false,
|
||||
publicShareEnabled: false,
|
||||
publicShareToken: null,
|
||||
};
|
||||
|
||||
export const currentThreadAtom = atom<CurrentThreadState>(initialState);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,53 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Toggle public share
|
||||
* Snapshot info
|
||||
*/
|
||||
export const togglePublicShareRequest = z.object({
|
||||
thread_id: z.number(),
|
||||
enabled: z.boolean(),
|
||||
export const snapshotInfo = z.object({
|
||||
id: z.number(),
|
||||
share_token: z.string(),
|
||||
public_url: z.string(),
|
||||
created_at: z.string(),
|
||||
message_count: z.number(),
|
||||
});
|
||||
|
||||
export const togglePublicShareResponse = z.object({
|
||||
enabled: z.boolean(),
|
||||
public_url: z.string().nullable(),
|
||||
share_token: z.string().nullable(),
|
||||
/**
|
||||
* Create snapshot
|
||||
*/
|
||||
export const createSnapshotRequest = z.object({
|
||||
thread_id: z.number(),
|
||||
});
|
||||
|
||||
export const createSnapshotResponse = z.object({
|
||||
snapshot_id: z.number(),
|
||||
share_token: z.string(),
|
||||
public_url: z.string(),
|
||||
is_new: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* List snapshots
|
||||
*/
|
||||
export const listSnapshotsRequest = z.object({
|
||||
thread_id: z.number(),
|
||||
});
|
||||
|
||||
export const listSnapshotsResponse = z.object({
|
||||
snapshots: z.array(snapshotInfo),
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete snapshot
|
||||
*/
|
||||
export const deleteSnapshotRequest = z.object({
|
||||
thread_id: z.number(),
|
||||
snapshot_id: z.number(),
|
||||
});
|
||||
|
||||
// Type exports
|
||||
export type TogglePublicShareRequest = z.infer<typeof togglePublicShareRequest>;
|
||||
export type TogglePublicShareResponse = z.infer<typeof togglePublicShareResponse>;
|
||||
export type SnapshotInfo = z.infer<typeof snapshotInfo>;
|
||||
export type CreateSnapshotRequest = z.infer<typeof createSnapshotRequest>;
|
||||
export type CreateSnapshotResponse = z.infer<typeof createSnapshotResponse>;
|
||||
export type ListSnapshotsRequest = z.infer<typeof listSnapshotsRequest>;
|
||||
export type ListSnapshotsResponse = z.infer<typeof listSnapshotsResponse>;
|
||||
export type DeleteSnapshotRequest = z.infer<typeof deleteSnapshotRequest>;
|
||||
|
|
|
|||
|
|
@ -203,6 +203,46 @@ export const listGoogleDriveFoldersResponse = z.object({
|
|||
items: z.array(googleDriveItem),
|
||||
});
|
||||
|
||||
/**
|
||||
* Slack channel with bot membership status
|
||||
*/
|
||||
export const slackChannel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
is_private: z.boolean(),
|
||||
is_member: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* List Slack channels
|
||||
*/
|
||||
export const listSlackChannelsRequest = z.object({
|
||||
connector_id: z.number(),
|
||||
});
|
||||
|
||||
export const listSlackChannelsResponse = z.array(slackChannel);
|
||||
|
||||
/**
|
||||
* Discord channel with indexing permission info
|
||||
*/
|
||||
export const discordChannel = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(["text", "announcement"]),
|
||||
position: z.number(),
|
||||
category_id: z.string().nullable().optional(),
|
||||
can_index: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* List Discord channels
|
||||
*/
|
||||
export const listDiscordChannelsRequest = z.object({
|
||||
connector_id: z.number(),
|
||||
});
|
||||
|
||||
export const listDiscordChannelsResponse = z.array(discordChannel);
|
||||
|
||||
// Inferred types
|
||||
export type SearchSourceConnectorType = z.infer<typeof searchSourceConnectorTypeEnum>;
|
||||
export type SearchSourceConnector = z.infer<typeof searchSourceConnector>;
|
||||
|
|
@ -223,3 +263,9 @@ export type ListGitHubRepositoriesResponse = z.infer<typeof listGitHubRepositori
|
|||
export type ListGoogleDriveFoldersRequest = z.infer<typeof listGoogleDriveFoldersRequest>;
|
||||
export type ListGoogleDriveFoldersResponse = z.infer<typeof listGoogleDriveFoldersResponse>;
|
||||
export type GoogleDriveItem = z.infer<typeof googleDriveItem>;
|
||||
export type SlackChannel = z.infer<typeof slackChannel>;
|
||||
export type ListSlackChannelsRequest = z.infer<typeof listSlackChannelsRequest>;
|
||||
export type ListSlackChannelsResponse = z.infer<typeof listSlackChannelsResponse>;
|
||||
export type DiscordChannel = z.infer<typeof discordChannel>;
|
||||
export type ListDiscordChannelsRequest = z.infer<typeof listDiscordChannelsRequest>;
|
||||
export type ListDiscordChannelsResponse = z.infer<typeof listDiscordChannelsResponse>;
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export const getPublicChatResponse = z.object({
|
|||
});
|
||||
|
||||
/**
|
||||
* Clone public chat (init)
|
||||
* Clone public chat
|
||||
*/
|
||||
export const clonePublicChatRequest = z.object({
|
||||
share_token: z.string(),
|
||||
|
|
@ -48,19 +48,6 @@ export const clonePublicChatRequest = z.object({
|
|||
export const clonePublicChatResponse = z.object({
|
||||
thread_id: z.number(),
|
||||
search_space_id: z.number(),
|
||||
share_token: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Complete clone
|
||||
*/
|
||||
export const completeCloneRequest = z.object({
|
||||
thread_id: z.number(),
|
||||
});
|
||||
|
||||
export const completeCloneResponse = z.object({
|
||||
status: z.string(),
|
||||
message_count: z.number(),
|
||||
});
|
||||
|
||||
// Type exports
|
||||
|
|
@ -71,5 +58,3 @@ export type GetPublicChatRequest = z.infer<typeof getPublicChatRequest>;
|
|||
export type GetPublicChatResponse = z.infer<typeof getPublicChatResponse>;
|
||||
export type ClonePublicChatRequest = z.infer<typeof clonePublicChatRequest>;
|
||||
export type ClonePublicChatResponse = z.infer<typeof clonePublicChatResponse>;
|
||||
export type CompleteCloneRequest = z.infer<typeof completeCloneRequest>;
|
||||
export type CompleteCloneResponse = z.infer<typeof completeCloneResponse>;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class BaseApiService {
|
|||
noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"];
|
||||
|
||||
// Prefixes that don't require auth (checked with startsWith)
|
||||
noAuthPrefixes: string[] = ["/api/v1/public/", "/api/v1/podcasts/"];
|
||||
noAuthPrefixes: string[] = ["/api/v1/public/"];
|
||||
|
||||
// Use a getter to always read fresh token from localStorage
|
||||
// This ensures the token is always up-to-date after login/logout
|
||||
|
|
|
|||
|
|
@ -1,31 +1,66 @@
|
|||
import {
|
||||
type TogglePublicShareRequest,
|
||||
type TogglePublicShareResponse,
|
||||
togglePublicShareRequest,
|
||||
togglePublicShareResponse,
|
||||
type CreateSnapshotRequest,
|
||||
type CreateSnapshotResponse,
|
||||
createSnapshotRequest,
|
||||
createSnapshotResponse,
|
||||
type DeleteSnapshotRequest,
|
||||
deleteSnapshotRequest,
|
||||
type ListSnapshotsRequest,
|
||||
type ListSnapshotsResponse,
|
||||
listSnapshotsRequest,
|
||||
listSnapshotsResponse,
|
||||
} from "@/contracts/types/chat-threads.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class ChatThreadsApiService {
|
||||
/**
|
||||
* Toggle public sharing for a thread.
|
||||
* Requires authentication.
|
||||
* Create a public snapshot for a thread.
|
||||
*/
|
||||
togglePublicShare = async (
|
||||
request: TogglePublicShareRequest
|
||||
): Promise<TogglePublicShareResponse> => {
|
||||
const parsed = togglePublicShareRequest.safeParse(request);
|
||||
createSnapshot = async (request: CreateSnapshotRequest): Promise<CreateSnapshotResponse> => {
|
||||
const parsed = createSnapshotRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.patch(
|
||||
`/api/v1/threads/${parsed.data.thread_id}/public-share`,
|
||||
togglePublicShareResponse,
|
||||
{ body: { enabled: parsed.data.enabled } }
|
||||
return baseApiService.post(
|
||||
`/api/v1/threads/${parsed.data.thread_id}/snapshots`,
|
||||
createSnapshotResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* List all snapshots for a thread.
|
||||
*/
|
||||
listSnapshots = async (request: ListSnapshotsRequest): Promise<ListSnapshotsResponse> => {
|
||||
const parsed = listSnapshotsRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.get(
|
||||
`/api/v1/threads/${parsed.data.thread_id}/snapshots`,
|
||||
listSnapshotsResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a specific snapshot.
|
||||
*/
|
||||
deleteSnapshot = async (request: DeleteSnapshotRequest): Promise<void> => {
|
||||
const parsed = deleteSnapshotRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
await baseApiService.delete(
|
||||
`/api/v1/threads/${parsed.data.thread_id}/snapshots/${parsed.data.snapshot_id}`
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
type DeleteConnectorRequest,
|
||||
deleteConnectorRequest,
|
||||
deleteConnectorResponse,
|
||||
type DiscordChannel,
|
||||
type GetConnectorRequest,
|
||||
type GetConnectorsRequest,
|
||||
getConnectorRequest,
|
||||
|
|
@ -16,10 +17,13 @@ import {
|
|||
indexConnectorResponse,
|
||||
type ListGitHubRepositoriesRequest,
|
||||
type ListGoogleDriveFoldersRequest,
|
||||
listDiscordChannelsResponse,
|
||||
listGitHubRepositoriesRequest,
|
||||
listGitHubRepositoriesResponse,
|
||||
listGoogleDriveFoldersRequest,
|
||||
listGoogleDriveFoldersResponse,
|
||||
listSlackChannelsResponse,
|
||||
type SlackChannel,
|
||||
type UpdateConnectorRequest,
|
||||
updateConnectorRequest,
|
||||
updateConnectorResponse,
|
||||
|
|
@ -335,6 +339,36 @@ class ConnectorsApiService {
|
|||
}
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Slack Connector Methods
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get Slack channels with bot membership status
|
||||
*/
|
||||
getSlackChannels = async (connectorId: number) => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/slack/connector/${connectorId}/channels`,
|
||||
listSlackChannelsResponse
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Discord Connector Methods
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get Discord text channels for a connector
|
||||
*/
|
||||
getDiscordChannels = async (connectorId: number) => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/discord/connector/${connectorId}/channels`,
|
||||
listDiscordChannelsResponse
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export type { SlackChannel, DiscordChannel };
|
||||
|
||||
export const connectorsApiService = new ConnectorsApiService();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import {
|
||||
type ClonePublicChatRequest,
|
||||
type ClonePublicChatResponse,
|
||||
type CompleteCloneRequest,
|
||||
type CompleteCloneResponse,
|
||||
clonePublicChatRequest,
|
||||
clonePublicChatResponse,
|
||||
completeCloneRequest,
|
||||
completeCloneResponse,
|
||||
type GetPublicChatRequest,
|
||||
type GetPublicChatResponse,
|
||||
getPublicChatRequest,
|
||||
|
|
@ -18,7 +14,6 @@ import { baseApiService } from "./base-api.service";
|
|||
class PublicChatApiService {
|
||||
/**
|
||||
* Get a public chat by share token.
|
||||
* No authentication required.
|
||||
*/
|
||||
getPublicChat = async (request: GetPublicChatRequest): Promise<GetPublicChatResponse> => {
|
||||
const parsed = getPublicChatRequest.safeParse(request);
|
||||
|
|
@ -33,8 +28,6 @@ class PublicChatApiService {
|
|||
|
||||
/**
|
||||
* Clone a public chat to the user's account.
|
||||
* Creates an empty thread and returns thread_id for redirect.
|
||||
* Requires authentication.
|
||||
*/
|
||||
clonePublicChat = async (request: ClonePublicChatRequest): Promise<ClonePublicChatResponse> => {
|
||||
const parsed = clonePublicChatRequest.safeParse(request);
|
||||
|
|
@ -49,25 +42,6 @@ class PublicChatApiService {
|
|||
clonePublicChatResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete the clone by copying messages and podcasts.
|
||||
* Called from the chat page after redirect.
|
||||
* Requires authentication.
|
||||
*/
|
||||
completeClone = async (request: CompleteCloneRequest): Promise<CompleteCloneResponse> => {
|
||||
const parsed = completeCloneRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.post(
|
||||
`/api/v1/threads/${parsed.data.thread_id}/complete-clone`,
|
||||
completeCloneResponse
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const publicChatApiService = new PublicChatApiService();
|
||||
|
|
|
|||
|
|
@ -24,9 +24,6 @@ export interface ThreadRecord {
|
|||
created_at: string;
|
||||
updated_at: string;
|
||||
has_comments?: boolean;
|
||||
public_share_enabled?: boolean;
|
||||
public_share_token?: string | null;
|
||||
clone_pending?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ const pendingSyncs = new Map<string, Promise<SyncHandle>>();
|
|||
// Version for sync state - increment this to force fresh sync when Electric config changes
|
||||
// v2: user-specific database architecture
|
||||
// v3: consistent cutoff date for sync+queries, visibility refresh support
|
||||
const SYNC_VERSION = 3;
|
||||
// v4: heartbeat-based stale notification detection with updated_at tracking
|
||||
const SYNC_VERSION = 4;
|
||||
|
||||
// Database name prefix for identifying SurfSense databases
|
||||
const DB_PREFIX = "surfsense-";
|
||||
|
|
|
|||
24
surfsense_web/lib/format-date.ts
Normal file
24
surfsense_web/lib/format-date.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
|
||||
|
||||
/**
|
||||
* Format a date string as a human-readable relative time
|
||||
* - < 1 min: "Just now"
|
||||
* - < 60 min: "15m ago"
|
||||
* - Today: "Today, 2:30 PM"
|
||||
* - Yesterday: "Yesterday, 2:30 PM"
|
||||
* - < 7 days: "3d ago"
|
||||
* - Older: "Jan 15, 2026"
|
||||
*/
|
||||
export function formatRelativeDate(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}m ago`;
|
||||
if (isToday(date)) return `Today, ${format(date, "h:mm a")}`;
|
||||
if (isYesterday(date)) return `Yesterday, ${format(date, "h:mm a")}`;
|
||||
if (daysAgo < 7) return `${daysAgo}d ago`;
|
||||
return format(date, "MMM d, yyyy");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue