mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/documents
This commit is contained in:
commit
103baa8b7a
41 changed files with 2054 additions and 475 deletions
|
|
@ -536,10 +536,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
role="textbox"
|
||||
aria-multiline="true"
|
||||
/>
|
||||
{/* Placeholder */}
|
||||
{/* Placeholder with fade animation on change */}
|
||||
{isEmpty && (
|
||||
<div
|
||||
className="absolute top-0 left-0 pointer-events-none text-muted-foreground text-sm"
|
||||
key={placeholder}
|
||||
className="absolute top-0 left-0 pointer-events-none text-muted-foreground text-sm animate-in fade-in duration-1000"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{placeholder}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,16 @@ import type { Document } from "@/contracts/types/document.types";
|
|||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Placeholder texts that cycle in new chats when input is empty */
|
||||
const CYCLING_PLACEHOLDERS = [
|
||||
"Ask SurfSense anything or @mention docs.",
|
||||
"Generate a podcast from marketing tips in the company handbook.",
|
||||
"Sum up our vacation policy from Drive.",
|
||||
"Give me a brief overview of the most urgent tickets in Jira and Linear.",
|
||||
"Create a concise table of today's top ten emails and calendar events.",
|
||||
"Check if this week's Slack messages reference any GitHub issues.",
|
||||
];
|
||||
|
||||
interface ThreadProps {
|
||||
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
||||
header?: React.ReactNode;
|
||||
|
|
@ -228,6 +238,30 @@ const Composer: FC = () => {
|
|||
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
|
||||
// Cycling placeholder state - only cycles in new chats
|
||||
const [placeholderIndex, setPlaceholderIndex] = useState(0);
|
||||
|
||||
// Cycle through placeholders every 4 seconds when thread is empty (new chat)
|
||||
useEffect(() => {
|
||||
// Only cycle when thread is empty (new chat)
|
||||
if (!isThreadEmpty) {
|
||||
// Reset to first placeholder when chat becomes active
|
||||
setPlaceholderIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
setPlaceholderIndex((prev) => (prev + 1) % CYCLING_PLACEHOLDERS.length);
|
||||
}, 6000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [isThreadEmpty]);
|
||||
|
||||
// Compute current placeholder - only cycle in new chats
|
||||
const currentPlaceholder = isThreadEmpty
|
||||
? CYCLING_PLACEHOLDERS[placeholderIndex]
|
||||
: CYCLING_PLACEHOLDERS[0];
|
||||
|
||||
// Live collaboration state
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
const { data: members } = useAtomValue(membersAtom);
|
||||
|
|
@ -410,7 +444,7 @@ const Composer: FC = () => {
|
|||
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
|
||||
<InlineMentionEditor
|
||||
ref={editorRef}
|
||||
placeholder="Ask SurfSense or @mention docs"
|
||||
placeholder={currentPlaceholder}
|
||||
onMentionTrigger={handleMentionTrigger}
|
||||
onMentionClose={handleMentionClose}
|
||||
onChange={handleEditorChange}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Globe, User, Users } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { createSnapshotMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms";
|
||||
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { createPublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -53,9 +54,17 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
|
||||
// Snapshot creation mutation
|
||||
const { mutateAsync: createSnapshot, isPending: isCreatingSnapshot } = useAtomValue(
|
||||
createSnapshotMutationAtom
|
||||
createPublicChatSnapshotMutationAtom
|
||||
);
|
||||
|
||||
// Permission check for public sharing
|
||||
const { data: access } = useAtomValue(myAccessAtom);
|
||||
const canCreatePublicLink = useMemo(() => {
|
||||
if (!access) return false;
|
||||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("public_sharing:create") ?? false;
|
||||
}, [access]);
|
||||
|
||||
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
||||
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
||||
|
||||
|
|
@ -183,35 +192,39 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
|||
);
|
||||
})}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border my-1" />
|
||||
{canCreatePublicLink && (
|
||||
<>
|
||||
{/* Divider */}
|
||||
<div className="border-t border-border my-1" />
|
||||
|
||||
{/* Public Link Option */}
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<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="text-sm font-medium">
|
||||
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||
Creates a shareable snapshot of this chat
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/* Public Link Option */}
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<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="text-sm font-medium">
|
||||
{isCreatingSnapshot ? "Creating link..." : "Create public link"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||
Creates a shareable snapshot of this chat
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { Copy, MessageSquare, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
|
||||
|
||||
interface PublicChatSnapshotRowProps {
|
||||
snapshot: PublicChatSnapshotDetail;
|
||||
canDelete: boolean;
|
||||
onCopy: (snapshot: PublicChatSnapshotDetail) => void;
|
||||
onDelete: (snapshot: PublicChatSnapshotDetail) => void;
|
||||
isDeleting?: boolean;
|
||||
}
|
||||
|
||||
export function PublicChatSnapshotRow({
|
||||
snapshot,
|
||||
canDelete,
|
||||
onCopy,
|
||||
onDelete,
|
||||
isDeleting = false,
|
||||
}: PublicChatSnapshotRowProps) {
|
||||
const formattedDate = new Date(snapshot.created_at).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 px-4 border-b last:border-b-0 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<h4 className="text-sm font-medium truncate" title={snapshot.thread_title}>
|
||||
{snapshot.thread_title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
<span>{formattedDate}</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{snapshot.message_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onCopy(snapshot)}
|
||||
className="h-8 px-2"
|
||||
title="Copy link"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(snapshot)}
|
||||
disabled={isDeleting}
|
||||
className="h-8 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
title="Delete link"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { Link2Off } from "lucide-react";
|
||||
|
||||
interface PublicChatSnapshotsEmptyStateProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function PublicChatSnapshotsEmptyState({
|
||||
title = "No public chat links",
|
||||
description = "When you create public links to share chats, they will appear here.",
|
||||
}: PublicChatSnapshotsEmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="rounded-full bg-muted p-3 mb-4">
|
||||
<Link2Off className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground max-w-sm">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
|
||||
import { PublicChatSnapshotRow } from "./public-chat-snapshot-row";
|
||||
import { PublicChatSnapshotsEmptyState } from "./public-chat-snapshots-empty-state";
|
||||
|
||||
interface PublicChatSnapshotsListProps {
|
||||
snapshots: PublicChatSnapshotDetail[];
|
||||
canDelete: boolean;
|
||||
onCopy: (snapshot: PublicChatSnapshotDetail) => void;
|
||||
onDelete: (snapshot: PublicChatSnapshotDetail) => void;
|
||||
deletingId?: number;
|
||||
}
|
||||
|
||||
export function PublicChatSnapshotsList({
|
||||
snapshots,
|
||||
canDelete,
|
||||
onCopy,
|
||||
onDelete,
|
||||
deletingId,
|
||||
}: PublicChatSnapshotsListProps) {
|
||||
if (snapshots.length === 0) {
|
||||
return <PublicChatSnapshotsEmptyState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border rounded-md divide-y">
|
||||
{snapshots.map((snapshot) => (
|
||||
<PublicChatSnapshotRow
|
||||
key={snapshot.id}
|
||||
snapshot={snapshot}
|
||||
canDelete={canDelete}
|
||||
onCopy={onCopy}
|
||||
onDelete={onDelete}
|
||||
isDeleting={deletingId === snapshot.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Globe, Info } from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { deletePublicChatSnapshotMutationAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-mutation.atoms";
|
||||
import { publicChatSnapshotsAtom } from "@/atoms/public-chat-snapshots/public-chat-snapshots-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { PublicChatSnapshotDetail } from "@/contracts/types/chat-threads.types";
|
||||
import { PublicChatSnapshotsList } from "./public-chat-snapshots-list";
|
||||
|
||||
interface PublicChatSnapshotsManagerProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
export function PublicChatSnapshotsManager({
|
||||
searchSpaceId: _searchSpaceId,
|
||||
}: PublicChatSnapshotsManagerProps) {
|
||||
const [deletingId, setDeletingId] = useState<number | undefined>();
|
||||
|
||||
// Data fetching
|
||||
const { data: snapshotsData, isLoading, isError } = useAtomValue(publicChatSnapshotsAtom);
|
||||
|
||||
// Permissions
|
||||
const { data: access } = useAtomValue(myAccessAtom);
|
||||
const canView = useMemo(() => {
|
||||
if (!access) return false;
|
||||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("public_sharing:view") ?? false;
|
||||
}, [access]);
|
||||
|
||||
const canDelete = useMemo(() => {
|
||||
if (!access) return false;
|
||||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("public_sharing:delete") ?? false;
|
||||
}, [access]);
|
||||
|
||||
// Mutations
|
||||
const { mutateAsync: deleteSnapshot } = useAtomValue(deletePublicChatSnapshotMutationAtom);
|
||||
|
||||
// Handlers
|
||||
const handleCopy = useCallback((snapshot: PublicChatSnapshotDetail) => {
|
||||
const publicUrl = `${window.location.origin}/public/${snapshot.share_token}`;
|
||||
navigator.clipboard.writeText(publicUrl);
|
||||
toast.success("Link copied to clipboard");
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (snapshot: PublicChatSnapshotDetail) => {
|
||||
try {
|
||||
setDeletingId(snapshot.id);
|
||||
await deleteSnapshot({
|
||||
thread_id: snapshot.thread_id,
|
||||
snapshot_id: snapshot.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete snapshot:", error);
|
||||
} finally {
|
||||
setDeletingId(undefined);
|
||||
}
|
||||
},
|
||||
[deleteSnapshot]
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
|
||||
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Failed to load public chat links. Please try again later.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// Permission denied
|
||||
if (!canView) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
You don't have permission to view public chat links in this search space.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const snapshots = snapshotsData?.snapshots ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<Alert className="py-3 md:py-4">
|
||||
<Globe className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
Public chat links allow anyone with the URL to view a snapshot of a chat. These links do
|
||||
not update when the original chat changes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 md:h-5 md:w-5" />
|
||||
Public Chat Links
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Manage public links to chats in this search space.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
<PublicChatSnapshotsList
|
||||
snapshots={snapshots}
|
||||
canDelete={canDelete}
|
||||
onCopy={handleCopy}
|
||||
onDelete={handleDelete}
|
||||
deletingId={deletingId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { Link2Off } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Navbar } from "@/components/homepage/navbar";
|
||||
|
||||
export function PublicChatNotFound() {
|
||||
const t = useTranslations("public_chat");
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
|
||||
<Navbar />
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-6 px-4 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Link2Off className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-semibold">{t("not_found_title")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<Link href="/login" className="text-primary underline hover:text-primary/80">
|
||||
{t("click_here")}
|
||||
</Link>{" "}
|
||||
{t("sign_in_prompt")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
|||
import { usePublicChat } from "@/hooks/use-public-chat";
|
||||
import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime";
|
||||
import { PublicChatFooter } from "./public-chat-footer";
|
||||
import { PublicChatNotFound } from "./public-chat-not-found";
|
||||
import { PublicThread } from "./public-thread";
|
||||
|
||||
interface PublicChatViewProps {
|
||||
|
|
@ -32,17 +33,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
|
|||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
|
||||
<Navbar />
|
||||
<div className="flex h-screen flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<h1 className="text-2xl font-semibold">Chat not found</h1>
|
||||
<p className="text-muted-foreground">
|
||||
This chat may have been removed or is no longer public.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
return <PublicChatNotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue