mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-22 21:28:12 +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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
},
|
||||
}));
|
||||
|
|
@ -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");
|
||||
},
|
||||
}));
|
||||
|
|
@ -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),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 开始您自己的对话。"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue