From 37adc54d6a9ea85010df8a19a703e21126594bdd Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 17:08:26 +0200 Subject: [PATCH] feat: add public chat frontend --- surfsense_web/app/public/[token]/page.tsx | 11 ++ surfsense_web/components/homepage/navbar.tsx | 8 +- .../public-chat/public-chat-footer.tsx | 56 ++++++ .../public-chat/public-chat-header.tsx | 34 ++++ .../public-chat/public-chat-view.tsx | 58 ++++++ .../components/public-chat/public-thread.tsx | 179 ++++++++++++++++++ .../hooks/use-public-chat-runtime.ts | 53 ++++++ surfsense_web/hooks/use-public-chat.ts | 14 ++ surfsense_web/lib/query-client/cache-keys.ts | 3 + 9 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 surfsense_web/app/public/[token]/page.tsx create mode 100644 surfsense_web/components/public-chat/public-chat-footer.tsx create mode 100644 surfsense_web/components/public-chat/public-chat-header.tsx create mode 100644 surfsense_web/components/public-chat/public-chat-view.tsx create mode 100644 surfsense_web/components/public-chat/public-thread.tsx create mode 100644 surfsense_web/hooks/use-public-chat-runtime.ts create mode 100644 surfsense_web/hooks/use-public-chat.ts diff --git a/surfsense_web/app/public/[token]/page.tsx b/surfsense_web/app/public/[token]/page.tsx new file mode 100644 index 000000000..530664ac6 --- /dev/null +++ b/surfsense_web/app/public/[token]/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { useParams } from "next/navigation"; +import { PublicChatView } from "@/components/public-chat/public-chat-view"; + +export default function PublicChatPage() { + const params = useParams(); + const token = params.token as string; + + return ; +} diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx index 2a8820bd6..c83d3556a 100644 --- a/surfsense_web/components/homepage/navbar.tsx +++ b/surfsense_web/components/homepage/navbar.tsx @@ -1,5 +1,11 @@ "use client"; -import { IconBrandDiscord, IconBrandGithub, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react"; +import { + IconBrandDiscord, + IconBrandGithub, + IconBrandReddit, + IconMenu2, + IconX, +} from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useEffect, useState } from "react"; diff --git a/surfsense_web/components/public-chat/public-chat-footer.tsx b/surfsense_web/components/public-chat/public-chat-footer.tsx new file mode 100644 index 000000000..06e3d9975 --- /dev/null +++ b/surfsense_web/components/public-chat/public-chat-footer.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { Copy, Loader2 } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { publicChatApiService } from "@/lib/apis/public-chat-api.service"; +import { getBearerToken } from "@/lib/auth-utils"; + +interface PublicChatFooterProps { + shareToken: string; +} + +export function PublicChatFooter({ shareToken }: PublicChatFooterProps) { + const router = useRouter(); + const [isCloning, setIsCloning] = useState(false); + + const handleCopyAndContinue = async () => { + const token = getBearerToken(); + + if (!token) { + const returnUrl = encodeURIComponent(`/public/${shareToken}`); + router.push(`/login?returnUrl=${returnUrl}&action=clone`); + return; + } + + setIsCloning(true); + + try { + await publicChatApiService.clonePublicChat({ + share_token: shareToken, + }); + + toast.success("Copying chat to your account...", { + description: "You'll be notified when it's ready.", + }); + + router.push("/dashboard"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to copy chat"; + toast.error(message); + } finally { + setIsCloning(false); + } + }; + + return ( +
+ +
+ ); +} diff --git a/surfsense_web/components/public-chat/public-chat-header.tsx b/surfsense_web/components/public-chat/public-chat-header.tsx new file mode 100644 index 000000000..6f6e40a52 --- /dev/null +++ b/surfsense_web/components/public-chat/public-chat-header.tsx @@ -0,0 +1,34 @@ +import { formatDistanceToNow } from "date-fns"; +import Image from "next/image"; +import Link from "next/link"; + +interface PublicChatHeaderProps { + title: string; + createdAt: string; +} + +export function PublicChatHeader({ title, createdAt }: PublicChatHeaderProps) { + const timeAgo = formatDistanceToNow(new Date(createdAt), { addSuffix: true }); + + return ( +
+
+
+ + SurfSense + +
+

{title}

+

{timeAgo}

+
+
+
+
+ ); +} diff --git a/surfsense_web/components/public-chat/public-chat-view.tsx b/surfsense_web/components/public-chat/public-chat-view.tsx new file mode 100644 index 000000000..1b7543712 --- /dev/null +++ b/surfsense_web/components/public-chat/public-chat-view.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { Loader2 } from "lucide-react"; +import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; +import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; +import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; +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 { PublicChatHeader } from "./public-chat-header"; +import { PublicThread } from "./public-thread"; + +interface PublicChatViewProps { + shareToken: string; +} + +export function PublicChatView({ shareToken }: PublicChatViewProps) { + const { data, isLoading, error } = usePublicChat(shareToken); + const runtime = usePublicChatRuntime({ data }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+

Chat not found

+

+ This chat may have been removed or is no longer public. +

+
+ ); + } + + return ( + + {/* Tool UIs for rendering tool results */} + + + + + +
+ } + footer={} + /> +
+
+ ); +} diff --git a/surfsense_web/components/public-chat/public-thread.tsx b/surfsense_web/components/public-chat/public-thread.tsx new file mode 100644 index 000000000..2fe1ecff6 --- /dev/null +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { + ActionBarPrimitive, + AssistantIf, + MessagePrimitive, + ThreadPrimitive, + useAssistantState, +} from "@assistant-ui/react"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { type FC, type ReactNode, useState } from "react"; +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { cn } from "@/lib/utils"; + +interface PublicThreadProps { + header?: ReactNode; + footer?: ReactNode; +} + +/** + * Read-only thread component for public chat viewing. + * No composer, no edit capabilities - just message display. + */ +export const PublicThread: FC = ({ header, footer }) => { + return ( + + + {header} + + + + {/* Spacer to ensure footer doesn't overlap last message */} +
+ + + {footer && ( +
+ {footer} +
+ )} + + ); +}; + +/** + * User avatar component with fallback to initials + */ +interface AuthorMetadata { + displayName: string | null; + avatarUrl: string | null; +} + +const UserAvatar: FC void }> = ({ + displayName, + avatarUrl, + hasError, + onError, +}) => { + const initials = displayName + ? displayName + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) + : "U"; + + if (avatarUrl && !hasError) { + return ( + {displayName + ); + } + + return ( +
+ {initials} +
+ ); +}; + +const PublicUserMessage: FC = () => { + const metadata = useAssistantState(({ message }) => message?.metadata); + const author = metadata?.custom?.author as AuthorMetadata | undefined; + + return ( + +
+
+
+ +
+
+ {author && ( +
+ +
+ )} +
+
+ ); +}; + +const UserAvatarWithState: FC = ({ displayName, avatarUrl }) => { + const [hasError, setHasError] = useState(false); + return ( + setHasError(true)} + /> + ); +}; + +const PublicAssistantMessage: FC = () => { + return ( + +
+ +
+ +
+ +
+
+ ); +}; + +const PublicAssistantActionBar: FC = () => { + return ( + + + + message.isCopied}> + + + !message.isCopied}> + + + + + + ); +}; diff --git a/surfsense_web/hooks/use-public-chat-runtime.ts b/surfsense_web/hooks/use-public-chat-runtime.ts new file mode 100644 index 000000000..cc7e95fdc --- /dev/null +++ b/surfsense_web/hooks/use-public-chat-runtime.ts @@ -0,0 +1,53 @@ +"use client"; + +import { + type AppendMessage, + type ThreadMessageLike, + useExternalStoreRuntime, +} from "@assistant-ui/react"; +import { useCallback, useMemo } from "react"; +import type { GetPublicChatResponse, PublicChatMessage } from "@/contracts/types/public-chat.types"; + +interface UsePublicChatRuntimeOptions { + data: GetPublicChatResponse | undefined; +} + +/** + * Creates a read-only runtime for public chat viewing. + */ +export function usePublicChatRuntime({ data }: UsePublicChatRuntimeOptions) { + const messages = useMemo(() => data?.messages ?? [], [data?.messages]); + + // No-op - public chat is read-only + const onNew = useCallback(async (_message: AppendMessage) => {}, []); + + // Convert PublicChatMessage to ThreadMessageLike + const convertMessage = useCallback( + (msg: PublicChatMessage, idx: number): ThreadMessageLike => ({ + id: `public-msg-${idx}`, + role: msg.role as "user" | "assistant", + content: msg.content as ThreadMessageLike["content"], + createdAt: new Date(msg.created_at), + metadata: msg.author + ? { + custom: { + author: { + displayName: msg.author.display_name, + avatarUrl: msg.author.avatar_url, + }, + }, + } + : undefined, + }), + [] + ); + + const runtime = useExternalStoreRuntime({ + isRunning: false, + messages, + onNew, + convertMessage, + }); + + return runtime; +} diff --git a/surfsense_web/hooks/use-public-chat.ts b/surfsense_web/hooks/use-public-chat.ts new file mode 100644 index 000000000..83f34712e --- /dev/null +++ b/surfsense_web/hooks/use-public-chat.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import type { GetPublicChatResponse } from "@/contracts/types/public-chat.types"; +import { publicChatApiService } from "@/lib/apis/public-chat-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export function usePublicChat(shareToken: string) { + return useQuery({ + queryKey: cacheKeys.publicChat.byToken(shareToken), + queryFn: () => publicChatApiService.getPublicChat({ share_token: shareToken }), + enabled: !!shareToken, + staleTime: 30_000, + retry: false, + }); +} diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 72f2bbd54..19ddbce7b 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -75,4 +75,7 @@ export const cacheKeys = { comments: { byMessage: (messageId: number) => ["comments", "message", messageId] as const, }, + publicChat: { + byToken: (shareToken: string) => ["public-chat", shareToken] as const, + }, };