Merge remote-tracking branch 'upstream/dev' into fix/documents

This commit is contained in:
Anish Sarkar 2026-02-04 03:04:52 +05:30
commit 103baa8b7a
41 changed files with 2054 additions and 475 deletions

View file

@ -6,6 +6,7 @@ import {
Brain,
ChevronRight,
FileText,
Globe,
type LucideIcon,
Menu,
MessageSquare,
@ -16,6 +17,7 @@ import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { PublicChatSnapshotsManager } from "@/components/public-chat-snapshots/public-chat-snapshots-manager";
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager";
@ -56,6 +58,12 @@ const settingsNavItems: SettingsNavItem[] = [
descriptionKey: "nav_system_instructions_desc",
icon: MessageSquare,
},
{
id: "public-links",
labelKey: "nav_public_links",
descriptionKey: "nav_public_links_desc",
icon: Globe,
},
];
function SettingsSidebar({
@ -276,6 +284,9 @@ function SettingsContent({
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
{activeSection === "public-links" && (
<PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />
)}
</motion.div>
</AnimatePresence>
</div>

View file

@ -11,6 +11,7 @@ import {
Crown,
Edit2,
FileText,
Globe,
Hash,
Link2,
LinkIcon,
@ -206,7 +207,15 @@ export default function TeamManagementPage() {
);
const handleUpdateRole = useCallback(
async (roleId: number, data: { permissions?: string[] }): Promise<Role> => {
async (
roleId: number,
data: {
name?: string;
description?: string | null;
permissions?: string[];
is_default?: boolean;
}
): Promise<Role> => {
const request: UpdateRoleRequest = {
search_space_id: searchSpaceId,
role_id: roleId,
@ -827,6 +836,12 @@ const CATEGORY_CONFIG: Record<
description: "Manage search space settings",
order: 10,
},
public_sharing: {
label: "Public Chat Sharing",
icon: Globe,
description: "Share chats publicly via links",
order: 11,
},
};
const ACTION_LABELS: Record<string, string> = {
@ -944,7 +959,7 @@ function RolesTab({
roles,
groupedPermissions,
loading,
onUpdateRole: _onUpdateRole,
onUpdateRole,
onDeleteRole,
onCreateRole,
canUpdate,
@ -954,7 +969,15 @@ function RolesTab({
roles: Role[];
groupedPermissions: Record<string, PermissionWithDescription[]>;
loading: boolean;
onUpdateRole: (roleId: number, data: { permissions?: string[] }) => Promise<Role>;
onUpdateRole: (
roleId: number,
data: {
name?: string;
description?: string | null;
permissions?: string[];
is_default?: boolean;
}
) => Promise<Role>;
onDeleteRole: (roleId: number) => Promise<boolean>;
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
canUpdate: boolean;
@ -962,6 +985,7 @@ function RolesTab({
canCreate: boolean;
}) {
const [showCreateRole, setShowCreateRole] = useState(false);
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
if (loading) {
return (
@ -997,6 +1021,21 @@ function RolesTab({
/>
)}
{/* Edit Role Form */}
{editingRoleId !== null &&
(() => {
const roleToEdit = roles.find((r) => r.id === editingRoleId);
if (!roleToEdit) return null;
return (
<EditRoleSection
role={roleToEdit}
groupedPermissions={groupedPermissions}
onUpdateRole={onUpdateRole}
onCancel={() => setEditingRoleId(null)}
/>
);
})()}
{/* Roles Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{roles.map((role, index) => (
@ -1055,13 +1094,9 @@ function RolesTab({
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" onCloseAutoFocus={(e) => e.preventDefault()}>
{canUpdate && (
<DropdownMenuItem
onClick={() => {
// TODO: Implement edit role dialog/modal
}}
>
<DropdownMenuItem onClick={() => setEditingRoleId(role.id)}>
<Edit2 className="h-4 w-4 mr-2" />
Edit Role
</DropdownMenuItem>
@ -2026,3 +2061,371 @@ function CreateRoleSection({
</motion.div>
);
}
function EditRoleSection({
role,
groupedPermissions,
onUpdateRole,
onCancel,
}: {
role: Role;
groupedPermissions: Record<string, PermissionWithDescription[]>;
onUpdateRole: (
roleId: number,
data: {
name?: string;
description?: string | null;
permissions?: string[];
is_default?: boolean;
}
) => Promise<Role>;
onCancel: () => void;
}) {
const [saving, setSaving] = useState(false);
const [name, setName] = useState(role.name);
const [description, setDescription] = useState(role.description || "");
const [selectedPermissions, setSelectedPermissions] = useState<string[]>(role.permissions);
const [isDefault, setIsDefault] = useState(role.is_default);
const [expandedCategories, setExpandedCategories] = useState<string[]>([]);
// Sort categories by order
const sortedCategories = useMemo(() => {
return Object.keys(groupedPermissions).sort((a, b) => {
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
return orderA - orderB;
});
}, [groupedPermissions]);
const handleSave = async () => {
if (!name.trim()) {
toast.error("Please enter a role name");
return;
}
setSaving(true);
try {
await onUpdateRole(role.id, {
name: name.trim(),
description: description.trim() || null,
permissions: selectedPermissions,
is_default: isDefault,
});
toast.success("Role updated successfully");
onCancel();
} catch (error) {
console.error("Failed to update role:", error);
toast.error("Failed to update role");
} finally {
setSaving(false);
}
};
const togglePermission = useCallback((perm: string) => {
setSelectedPermissions((prev) =>
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
);
}, []);
const toggleCategory = useCallback(
(category: string) => {
const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || [];
const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p));
if (allSelected) {
setSelectedPermissions((prev) => prev.filter((p) => !categoryPerms.includes(p)));
} else {
setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]);
}
},
[groupedPermissions, selectedPermissions]
);
const toggleCategoryExpanded = useCallback((category: string) => {
setExpandedCategories((prev) =>
prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category]
);
}, []);
const getCategoryStats = useCallback(
(category: string) => {
const perms = groupedPermissions[category] || [];
const selected = perms.filter((p) => selectedPermissions.includes(p.value)).length;
return { selected, total: perms.length, allSelected: selected === perms.length };
},
[groupedPermissions, selectedPermissions]
);
return (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="mb-6"
>
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 via-background to-background">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Edit2 className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg">Edit Role</CardTitle>
<CardDescription className="text-sm">
Modify permissions for "{role.name}"
</CardDescription>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onCancel}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Role Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-role-name">Role Name *</Label>
<Input
id="edit-role-name"
placeholder="e.g., Content Manager"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-role-description">Description</Label>
<Input
id="edit-role-description"
placeholder="Brief description of this role"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
{/* Default Role Checkbox */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<Checkbox
id="edit-is-default"
checked={isDefault}
onCheckedChange={(checked) => setIsDefault(checked === true)}
/>
<div className="flex-1">
<Label htmlFor="edit-is-default" className="cursor-pointer font-medium">
Set as default role
</Label>
<p className="text-xs text-muted-foreground">
New members without a specific role will be assigned this role
</p>
</div>
</div>
{/* Permissions Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Permissions ({selectedPermissions.length} selected)
</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs h-7"
onClick={() =>
setExpandedCategories(
expandedCategories.length === sortedCategories.length ? [] : sortedCategories
)
}
>
{expandedCategories.length === sortedCategories.length
? "Collapse All"
: "Expand All"}
</Button>
</div>
<div className="space-y-2">
{sortedCategories.map((category) => {
const config = CATEGORY_CONFIG[category] || {
label: category,
icon: FileText,
description: "",
order: 99,
};
const IconComponent = config.icon;
const stats = getCategoryStats(category);
const isExpanded = expandedCategories.includes(category);
const perms = groupedPermissions[category] || [];
return (
<div key={category} className="rounded-lg border bg-card overflow-hidden">
{/* Category Header */}
<div
className={cn(
"flex items-center justify-between p-3 cursor-pointer hover:bg-muted/50 transition-colors",
stats.allSelected && "bg-primary/5"
)}
onClick={() => toggleCategoryExpanded(category)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCategoryExpanded(category);
}
}}
tabIndex={0}
role="button"
>
<div className="flex items-center gap-3">
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
stats.selected > 0 ? "bg-primary/10" : "bg-muted"
)}
>
<IconComponent
className={cn(
"h-4 w-4",
stats.selected > 0 ? "text-primary" : "text-muted-foreground"
)}
/>
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{config.label}</span>
<Badge
variant={stats.selected > 0 ? "default" : "secondary"}
className="text-xs h-5"
>
{stats.selected}/{stats.total}
</Badge>
</div>
<p className="text-xs text-muted-foreground hidden md:block">
{config.description}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={stats.allSelected}
onCheckedChange={() => toggleCategory(category)}
onClick={(e) => e.stopPropagation()}
aria-label={`Select all ${config.label} permissions`}
/>
<motion.div
animate={{ rotate: isExpanded ? 180 : 0 }}
transition={{ duration: 0.2 }}
>
<svg
className="h-4 w-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</motion.div>
</div>
</div>
{/* Permissions List */}
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t"
>
<div className="p-3 space-y-1">
{perms.map((perm) => {
const action = perm.value.split(":")[1];
const actionConfig = ACTION_DISPLAY[action] || {
label: action,
color: "text-gray-600 bg-gray-500/10",
};
const isSelected = selectedPermissions.includes(perm.value);
return (
<div
key={perm.value}
className={cn(
"flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors",
isSelected
? "bg-primary/10 hover:bg-primary/15"
: "hover:bg-muted/50"
)}
onClick={() => togglePermission(perm.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
togglePermission(perm.value);
}
}}
tabIndex={0}
role="checkbox"
aria-checked={isSelected}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Checkbox
checked={isSelected}
onCheckedChange={() => togglePermission(perm.value)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
"text-xs font-medium px-2 py-0.5 rounded",
actionConfig.color
)}
>
{actionConfig.label}
</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{perm.description}
</p>
</div>
</div>
</div>
);
})}
</div>
</motion.div>
)}
</div>
);
})}
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving || !name.trim()}>
{saving ? (
<>
<Spinner size="sm" className="mr-2" />
Saving...
</>
) : (
<>
<Check className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</CardContent>
</Card>
</motion.div>
);
}

View file

@ -1,31 +0,0 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
CreateSnapshotRequest,
CreateSnapshotResponse,
} from "@/contracts/types/chat-threads.types";
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
export const createSnapshotMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: CreateSnapshotRequest) => {
return chatThreadsApiService.createSnapshot(request);
},
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 link copied to clipboard", {
description: "This snapshot already exists",
});
}
},
onError: (error: Error) => {
console.error("Failed to create snapshot:", error);
toast.error("Failed to create public link");
},
}));

View file

@ -0,0 +1,53 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
PublicChatSnapshotCreateRequest,
PublicChatSnapshotCreateResponse,
PublicChatSnapshotDeleteRequest,
} from "@/contracts/types/chat-threads.types";
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
export const createPublicChatSnapshotMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: PublicChatSnapshotCreateRequest) => {
return chatThreadsApiService.createPublicChatSnapshot(request);
},
onSuccess: (response: PublicChatSnapshotCreateResponse) => {
queryClient.invalidateQueries({
queryKey: cacheKeys.publicChatSnapshots.all,
});
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 link copied to clipboard", {
description: "This snapshot already exists",
});
}
},
onError: (error: Error) => {
console.error("Failed to create snapshot:", error);
toast.error("Failed to create public link");
},
}));
export const deletePublicChatSnapshotMutationAtom = atomWithMutation(() => ({
mutationFn: async (request: PublicChatSnapshotDeleteRequest) => {
return chatThreadsApiService.deletePublicChatSnapshot(request);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: cacheKeys.publicChatSnapshots.all,
});
toast.success("Public link deleted");
},
onError: (error: Error) => {
console.error("Failed to delete public chat link:", error);
toast.error("Failed to delete public link");
},
}));

View file

@ -0,0 +1,22 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const publicChatSnapshotsAtom = atomWithQuery((get) => {
const searchSpaceId = get(activeSearchSpaceIdAtom);
return {
queryKey: cacheKeys.publicChatSnapshots.bySearchSpace(Number(searchSpaceId) || 0),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!searchSpaceId) {
return { snapshots: [] };
}
return chatThreadsApiService.listPublicChatSnapshotsForSearchSpace({
search_space_id: Number(searchSpaceId),
});
},
};
});

View file

@ -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}

View file

@ -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}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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 (

View file

@ -1,9 +1,9 @@
import { z } from "zod";
/**
* Snapshot info
* Public chat snapshot info
*/
export const snapshotInfo = z.object({
export const publicChatSnapshotInfo = z.object({
id: z.number(),
share_token: z.string(),
public_url: z.string(),
@ -12,13 +12,13 @@ export const snapshotInfo = z.object({
});
/**
* Create snapshot
* Create public chat snapshot
*/
export const createSnapshotRequest = z.object({
export const publicChatSnapshotCreateRequest = z.object({
thread_id: z.number(),
});
export const createSnapshotResponse = z.object({
export const publicChatSnapshotCreateResponse = z.object({
snapshot_id: z.number(),
share_token: z.string(),
public_url: z.string(),
@ -26,28 +26,55 @@ export const createSnapshotResponse = z.object({
});
/**
* List snapshots
* List public chat snapshots for thread
*/
export const listSnapshotsRequest = z.object({
export const publicChatSnapshotListRequest = z.object({
thread_id: z.number(),
});
export const listSnapshotsResponse = z.object({
snapshots: z.array(snapshotInfo),
export const publicChatSnapshotListResponse = z.object({
snapshots: z.array(publicChatSnapshotInfo),
});
/**
* Delete snapshot
* Delete public chat snapshot
*/
export const deleteSnapshotRequest = z.object({
export const publicChatSnapshotDeleteRequest = z.object({
thread_id: z.number(),
snapshot_id: z.number(),
});
/**
* Public chat snapshot with thread context
*/
export const publicChatSnapshotDetail = z.object({
id: z.number(),
share_token: z.string(),
public_url: z.string(),
created_at: z.string(),
message_count: z.number(),
thread_id: z.number(),
thread_title: z.string(),
});
/**
* List public chat snapshots for search space
*/
export const publicChatSnapshotsBySpaceRequest = z.object({
search_space_id: z.number(),
});
export const publicChatSnapshotsBySpaceResponse = z.object({
snapshots: z.array(publicChatSnapshotDetail),
});
// Type exports
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>;
export type PublicChatSnapshotInfo = z.infer<typeof publicChatSnapshotInfo>;
export type PublicChatSnapshotCreateRequest = z.infer<typeof publicChatSnapshotCreateRequest>;
export type PublicChatSnapshotCreateResponse = z.infer<typeof publicChatSnapshotCreateResponse>;
export type PublicChatSnapshotListRequest = z.infer<typeof publicChatSnapshotListRequest>;
export type PublicChatSnapshotListResponse = z.infer<typeof publicChatSnapshotListResponse>;
export type PublicChatSnapshotDeleteRequest = z.infer<typeof publicChatSnapshotDeleteRequest>;
export type PublicChatSnapshotDetail = z.infer<typeof publicChatSnapshotDetail>;
export type PublicChatSnapshotsBySpaceRequest = z.infer<typeof publicChatSnapshotsBySpaceRequest>;
export type PublicChatSnapshotsBySpaceResponse = z.infer<typeof publicChatSnapshotsBySpaceResponse>;

View file

@ -1,24 +1,30 @@
import {
type CreateSnapshotRequest,
type CreateSnapshotResponse,
createSnapshotRequest,
createSnapshotResponse,
type DeleteSnapshotRequest,
deleteSnapshotRequest,
type ListSnapshotsRequest,
type ListSnapshotsResponse,
listSnapshotsRequest,
listSnapshotsResponse,
type PublicChatSnapshotCreateRequest,
type PublicChatSnapshotCreateResponse,
type PublicChatSnapshotDeleteRequest,
type PublicChatSnapshotListRequest,
type PublicChatSnapshotListResponse,
type PublicChatSnapshotsBySpaceRequest,
type PublicChatSnapshotsBySpaceResponse,
publicChatSnapshotCreateRequest,
publicChatSnapshotCreateResponse,
publicChatSnapshotDeleteRequest,
publicChatSnapshotListRequest,
publicChatSnapshotListResponse,
publicChatSnapshotsBySpaceRequest,
publicChatSnapshotsBySpaceResponse,
} from "@/contracts/types/chat-threads.types";
import { ValidationError } from "../error";
import { baseApiService } from "./base-api.service";
class ChatThreadsApiService {
/**
* Create a public snapshot for a thread.
* Create a public chat snapshot for a thread.
*/
createSnapshot = async (request: CreateSnapshotRequest): Promise<CreateSnapshotResponse> => {
const parsed = createSnapshotRequest.safeParse(request);
createPublicChatSnapshot = async (
request: PublicChatSnapshotCreateRequest
): Promise<PublicChatSnapshotCreateResponse> => {
const parsed = publicChatSnapshotCreateRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
@ -27,15 +33,17 @@ class ChatThreadsApiService {
return baseApiService.post(
`/api/v1/threads/${parsed.data.thread_id}/snapshots`,
createSnapshotResponse
publicChatSnapshotCreateResponse
);
};
/**
* List all snapshots for a thread.
* List all public chat snapshots for a thread.
*/
listSnapshots = async (request: ListSnapshotsRequest): Promise<ListSnapshotsResponse> => {
const parsed = listSnapshotsRequest.safeParse(request);
listPublicChatSnapshots = async (
request: PublicChatSnapshotListRequest
): Promise<PublicChatSnapshotListResponse> => {
const parsed = publicChatSnapshotListRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
@ -44,15 +52,15 @@ class ChatThreadsApiService {
return baseApiService.get(
`/api/v1/threads/${parsed.data.thread_id}/snapshots`,
listSnapshotsResponse
publicChatSnapshotListResponse
);
};
/**
* Delete a specific snapshot.
* Delete a public chat snapshot.
*/
deleteSnapshot = async (request: DeleteSnapshotRequest): Promise<void> => {
const parsed = deleteSnapshotRequest.safeParse(request);
deletePublicChatSnapshot = async (request: PublicChatSnapshotDeleteRequest): Promise<void> => {
const parsed = publicChatSnapshotDeleteRequest.safeParse(request);
if (!parsed.success) {
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
@ -63,6 +71,25 @@ class ChatThreadsApiService {
`/api/v1/threads/${parsed.data.thread_id}/snapshots/${parsed.data.snapshot_id}`
);
};
/**
* List all public chat snapshots for a search space.
*/
listPublicChatSnapshotsForSearchSpace = async (
request: PublicChatSnapshotsBySpaceRequest
): Promise<PublicChatSnapshotsBySpaceResponse> => {
const parsed = publicChatSnapshotsBySpaceRequest.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/searchspaces/${parsed.data.search_space_id}/snapshots`,
publicChatSnapshotsBySpaceResponse
);
};
}
export const chatThreadsApiService = new ChatThreadsApiService();

View file

@ -82,4 +82,9 @@ export const cacheKeys = {
publicChat: {
byToken: (shareToken: string) => ["public-chat", shareToken] as const,
},
publicChatSnapshots: {
all: ["public-chat-snapshots"] as const,
bySearchSpace: (searchSpaceId: number) =>
["public-chat-snapshots", "search-space", searchSpaceId] as const,
},
};

View file

@ -731,6 +731,8 @@
"nav_role_assignments_desc": "Assign configs to agent roles",
"nav_system_instructions": "System Instructions",
"nav_system_instructions_desc": "SearchSpace-wide AI instructions",
"nav_public_links": "Public Chat Links",
"nav_public_links_desc": "Manage publicly shared chat links",
"general_name_label": "Name",
"general_name_placeholder": "Enter search space name",
"general_name_description": "A unique name for your search space.",
@ -794,5 +796,10 @@
"comments": "comments",
"example_comment": "Let's discuss this tomorrow!"
}
},
"public_chat": {
"not_found_title": "This chat has been deleted.",
"click_here": "Click here",
"sign_in_prompt": "to log in to SurfSense and start your own."
}
}

View file

@ -716,6 +716,8 @@
"nav_role_assignments_desc": "为代理角色分配配置",
"nav_system_instructions": "系统指令",
"nav_system_instructions_desc": "搜索空间级别的 AI 指令",
"nav_public_links": "公开聊天链接",
"nav_public_links_desc": "管理公开分享的聊天链接",
"general_name_label": "名称",
"general_name_placeholder": "输入搜索空间名称",
"general_name_description": "您的搜索空间的唯一名称。",
@ -779,5 +781,10 @@
"comments": "评论",
"example_comment": "我们明天讨论这个!"
}
},
"public_chat": {
"not_found_title": "此对话已被删除。",
"click_here": "点击这里",
"sign_in_prompt": "登录 SurfSense 开始您自己的对话。"
}
}