From ee65e1377f3fb9142b9b88291042f2aae096cdba Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 26 Jan 2026 18:39:59 +0200 Subject: [PATCH] feat: improve public chat UI and shared components --- .vscode/settings.json | 3 +- .../app/services/public_chat_service.py | 15 ++- .../new-chat/[[...chat_id]]/page.tsx | 108 +---------------- .../atoms/chat/chat-thread-mutation.atoms.ts | 28 +++++ .../atoms/chat/current-thread.atom.ts | 4 + .../components/auth/sign-in-button.tsx | 88 ++++++++++++++ surfsense_web/components/homepage/navbar.tsx | 68 +---------- .../components/new-chat/chat-share-button.tsx | 114 ++++++++++++++++-- .../public-chat/public-chat-header.tsx | 34 ------ .../public-chat/public-chat-view.tsx | 52 ++++---- .../components/public-chat/public-thread.tsx | 12 +- .../hooks/use-public-chat-runtime.ts | 42 +++---- surfsense_web/lib/chat/message-utils.ts | 109 +++++++++++++++++ surfsense_web/lib/chat/thread-persistence.ts | 1 + 14 files changed, 403 insertions(+), 275 deletions(-) create mode 100644 surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts create mode 100644 surfsense_web/components/auth/sign-in-button.tsx delete mode 100644 surfsense_web/components/public-chat/public-chat-header.tsx create mode 100644 surfsense_web/lib/chat/message-utils.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index f134660b6..05bd30702 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "biome.configurationPath": "./surfsense_web/biome.json" + "biome.configurationPath": "./surfsense_web/biome.json", + "deepscan.ignoreConfirmWarning": true } \ No newline at end of file diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index 08523c1f2..42a26c403 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -23,9 +23,18 @@ UI_TOOLS = { def strip_citations(text: str) -> str: - """Remove [citation:X] and [citation:doc-X] patterns from text.""" - text = re.sub(r"\[citation:(doc-)?\d+\]", "", text) - text = re.sub(r"\s+", " ", text) + """ + Remove [citation:X] and [citation:doc-X] patterns from text. + Preserves newlines to maintain markdown formatting. + """ + # Remove citation patterns (including Chinese brackets 【】) + text = re.sub(r"[\[【]citation:(doc-)?\d+[\]】]", "", text) + # Collapse multiple spaces/tabs (but NOT newlines) into single space + text = re.sub(r"[^\S\n]+", " ", text) + # Normalize excessive blank lines (3+ newlines → 2) + text = re.sub(r"\n{3,}", "\n\n", text) + # Clean up spaces around newlines + text = re.sub(r" *\n *", "\n", text) return text.strip() diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 59e7878c4..2af50f8e2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -44,6 +44,7 @@ import { looksLikePodcastRequest, setActivePodcastTaskId, } from "@/lib/chat/podcast-state"; +import { convertToThreadMessage } from "@/lib/chat/message-utils"; import { appendMessage, type ChatVisibility, @@ -108,111 +109,6 @@ function extractMentionedDocuments(content: unknown): MentionedDocumentInfo[] { return []; } -/** - * Zod schema for persisted attachment info - */ -const PersistedAttachmentSchema = z.object({ - id: z.string(), - name: z.string(), - type: z.string(), - contentType: z.string().optional(), - imageDataUrl: z.string().optional(), - extractedContent: z.string().optional(), -}); - -const AttachmentsPartSchema = z.object({ - type: z.literal("attachments"), - items: z.array(PersistedAttachmentSchema), -}); - -type PersistedAttachment = z.infer; - -/** - * Extract persisted attachments from message content (type-safe with Zod) - */ -function extractPersistedAttachments(content: unknown): PersistedAttachment[] { - if (!Array.isArray(content)) return []; - - for (const part of content) { - const result = AttachmentsPartSchema.safeParse(part); - if (result.success) { - return result.data.items; - } - } - - return []; -} - -/** - * Convert backend message to assistant-ui ThreadMessageLike format - * Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps - * Restores attachments for user messages from persisted data - */ -function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { - let content: ThreadMessageLike["content"]; - - if (typeof msg.content === "string") { - content = [{ type: "text", text: msg.content }]; - } else if (Array.isArray(msg.content)) { - // Filter out custom metadata parts - they're handled separately - const filteredContent = msg.content.filter((part: unknown) => { - if (typeof part !== "object" || part === null || !("type" in part)) return true; - const partType = (part as { type: string }).type; - // Filter out thinking-steps, mentioned-documents, and attachments - return ( - partType !== "thinking-steps" && - partType !== "mentioned-documents" && - partType !== "attachments" - ); - }); - content = - filteredContent.length > 0 - ? (filteredContent as ThreadMessageLike["content"]) - : [{ type: "text", text: "" }]; - } else { - content = [{ type: "text", text: String(msg.content) }]; - } - - // Restore attachments for user messages - let attachments: ThreadMessageLike["attachments"]; - if (msg.role === "user") { - const persistedAttachments = extractPersistedAttachments(msg.content); - if (persistedAttachments.length > 0) { - attachments = persistedAttachments.map((att) => ({ - id: att.id, - name: att.name, - type: att.type as "document" | "image" | "file", - contentType: att.contentType || "application/octet-stream", - status: { type: "complete" as const }, - content: [], - // Custom fields for our ChatAttachment interface - imageDataUrl: att.imageDataUrl, - extractedContent: att.extractedContent, - })); - } - } - - // Build metadata.custom for author display in shared chats - const metadata = msg.author_id - ? { - custom: { - author: { - displayName: msg.author_display_name ?? null, - avatarUrl: msg.author_avatar_url ?? null, - }, - }, - } - : undefined; - - return { - id: `msg-${msg.id}`, - role: msg.role, - content, - createdAt: new Date(msg.created_at), - attachments, - metadata, - }; -} /** * Tools that should render custom UI in the chat. @@ -458,6 +354,8 @@ export default function NewChatPage() { visibility: currentThread?.visibility ?? null, hasComments: currentThread?.has_comments ?? false, addingCommentToMessageId: null, + publicShareEnabled: currentThread?.public_share_enabled ?? false, + publicShareToken: null, }); }, [currentThread, setCurrentThreadState]); diff --git a/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts b/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts new file mode 100644 index 000000000..a844a45fb --- /dev/null +++ b/surfsense_web/atoms/chat/chat-thread-mutation.atoms.ts @@ -0,0 +1,28 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { + TogglePublicShareRequest, + TogglePublicShareResponse, +} from "@/contracts/types/chat-threads.types"; +import { chatThreadsApiService } from "@/lib/apis/chat-threads-api.service"; + +export const togglePublicShareMutationAtom = atomWithMutation(() => ({ + mutationFn: async (request: TogglePublicShareRequest) => { + return chatThreadsApiService.togglePublicShare(request); + }, + onSuccess: (response: TogglePublicShareResponse) => { + if (response.enabled && response.share_token) { + const publicUrl = `${window.location.origin}/public/${response.share_token}`; + navigator.clipboard.writeText(publicUrl); + toast.success("Public link copied to clipboard", { + description: "Anyone with this link can view the chat", + }); + } else { + toast.success("Public sharing disabled"); + } + }, + onError: (error: Error) => { + console.error("Failed to toggle public share:", error); + toast.error("Failed to update public sharing"); + }, +})); diff --git a/surfsense_web/atoms/chat/current-thread.atom.ts b/surfsense_web/atoms/chat/current-thread.atom.ts index c19b2638c..7d6ccb0db 100644 --- a/surfsense_web/atoms/chat/current-thread.atom.ts +++ b/surfsense_web/atoms/chat/current-thread.atom.ts @@ -17,6 +17,8 @@ interface CurrentThreadState { visibility: ChatVisibility | null; hasComments: boolean; addingCommentToMessageId: number | null; + publicShareEnabled: boolean; + publicShareToken: string | null; } const initialState: CurrentThreadState = { @@ -24,6 +26,8 @@ const initialState: CurrentThreadState = { visibility: null, hasComments: false, addingCommentToMessageId: null, + publicShareEnabled: false, + publicShareToken: null, }; export const currentThreadAtom = atom(initialState); diff --git a/surfsense_web/components/auth/sign-in-button.tsx b/surfsense_web/components/auth/sign-in-button.tsx new file mode 100644 index 000000000..f7270df9a --- /dev/null +++ b/surfsense_web/components/auth/sign-in-button.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { motion } from "motion/react"; +import Link from "next/link"; +import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; +import { trackLoginAttempt } from "@/lib/posthog/events"; +import { cn } from "@/lib/utils"; + +// Official Google "G" logo with brand colors +const GoogleLogo = ({ className }: { className?: string }) => ( + + + + + + +); + +interface SignInButtonProps { + /** + * - "desktop": Hidden on mobile, visible on md+ (for navbar with separate mobile menu) + * - "mobile": Full width, always visible (for mobile menu) + * - "compact": Always visible, compact size (for headers) + */ + variant?: "desktop" | "mobile" | "compact"; +} + +export const SignInButton = ({ variant = "desktop" }: SignInButtonProps) => { + const isGoogleAuth = AUTH_TYPE === "GOOGLE"; + + const handleGoogleLogin = () => { + trackLoginAttempt("google"); + window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; + }; + + const getClassName = () => { + if (variant === "desktop") { + return isGoogleAuth + ? "hidden rounded-full bg-white px-5 py-2 text-sm text-neutral-700 shadow-md ring-1 ring-neutral-200/50 hover:shadow-lg md:flex dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50" + : "hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black"; + } + if (variant === "compact") { + return isGoogleAuth + ? "rounded-full bg-white px-4 py-1.5 text-sm text-neutral-700 shadow-md ring-1 ring-neutral-200/50 hover:shadow-lg dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50" + : "rounded-full bg-black px-6 py-1.5 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black"; + } + // mobile + return isGoogleAuth + ? "w-full rounded-lg bg-white px-8 py-2.5 text-neutral-700 shadow-md ring-1 ring-neutral-200/50 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 touch-manipulation" + : "w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black text-center touch-manipulation"; + }; + + if (isGoogleAuth) { + return ( + + + Sign In + + ); + } + + return ( + + Sign In + + ); +}; diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx index c83d3556a..4c66ac759 100644 --- a/surfsense_web/components/homepage/navbar.tsx +++ b/surfsense_web/components/homepage/navbar.tsx @@ -9,78 +9,12 @@ import { import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useEffect, useState } from "react"; +import { SignInButton } from "@/components/auth/sign-in-button"; import { Logo } from "@/components/Logo"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; import { useGithubStars } from "@/hooks/use-github-stars"; -import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; -import { trackLoginAttempt } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; -// Official Google "G" logo with brand colors -const GoogleLogo = ({ className }: { className?: string }) => ( - - - - - - -); - -// Sign in button component that handles both Google OAuth and local auth -const SignInButton = ({ variant = "desktop" }: { variant?: "desktop" | "mobile" }) => { - const isGoogleAuth = AUTH_TYPE === "GOOGLE"; - - const handleGoogleLogin = () => { - trackLoginAttempt("google"); - window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`; - }; - - if (isGoogleAuth) { - return ( - - - Sign In - - ); - } - - return ( - - Sign In - - ); -}; - export const Navbar = () => { const [isScrolled, setIsScrolled] = useState(false); diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index fcace2572..4e811779f 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -2,18 +2,15 @@ import { useQueryClient } from "@tanstack/react-query"; import { useAtomValue, useSetAtom } from "jotai"; -import { User, Users } from "lucide-react"; +import { Globe, Link2, User, Users } from "lucide-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; +import { togglePublicShareMutationAtom } from "@/atoms/chat/chat-thread-mutation.atoms"; import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom"; import { Button } from "@/components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { - type ChatVisibility, - type ThreadRecord, - updateThreadVisibility, -} from "@/lib/chat/thread-persistence"; +import { type ChatVisibility, type ThreadRecord, updateThreadVisibility } from "@/lib/chat/thread-persistence"; import { cn } from "@/lib/utils"; interface ChatShareButtonProps { @@ -48,11 +45,19 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS // Use Jotai atom for visibility (single source of truth) const currentThreadState = useAtomValue(currentThreadAtom); + const setCurrentThreadState = useSetAtom(currentThreadAtom); const setThreadVisibility = useSetAtom(setThreadVisibilityAtom); + // Public share mutation + const { mutateAsync: togglePublicShare, isPending: isTogglingPublic } = useAtomValue( + togglePublicShareMutationAtom + ); + // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; - const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it + const isPublicEnabled = + currentThreadState.publicShareEnabled ?? thread?.public_share_enabled ?? false; + const publicShareToken = currentThreadState.publicShareToken ?? null; const handleVisibilityChange = useCallback( async (newVisibility: ChatVisibility) => { @@ -87,12 +92,41 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS [thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility] ); + const handlePublicShareToggle = useCallback(async () => { + if (!thread) return; + + try { + const response = await togglePublicShare({ + thread_id: thread.id, + enabled: !isPublicEnabled, + }); + + // Update atom state with response + setCurrentThreadState((prev) => ({ + ...prev, + publicShareEnabled: response.enabled, + publicShareToken: response.share_token, + })); + } catch(error) { + console.error("Failed to toggle public share:", error); + } + }, [thread, isPublicEnabled, togglePublicShare, setCurrentThreadState]); + + const handleCopyPublicLink = useCallback(async () => { + if (!publicShareToken) return; + + const publicUrl = `${window.location.origin}/public/${publicShareToken}`; + await navigator.clipboard.writeText(publicUrl); + toast.success("Public link copied to clipboard"); + }, [publicShareToken]); + // Don't show if no thread (new chat that hasn't been created yet) if (!thread) { return null; } - const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users; + const CurrentIcon = isPublicEnabled ? Globe : currentVisibility === "PRIVATE" ? User : Users; + const buttonLabel = isPublicEnabled ? "Public" : currentVisibility === "PRIVATE" ? "Private" : "Shared"; return ( @@ -108,9 +142,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS )} > - - {currentVisibility === "PRIVATE" ? "Private" : "Shared"} - + {buttonLabel} @@ -124,6 +156,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS onCloseAutoFocus={(e) => e.preventDefault()} >
+ {/* Visibility Options */} {visibilityOptions.map((option) => { const isSelected = currentVisibility === option.value; const Icon = option.icon; @@ -166,6 +199,65 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS ); })} + + {/* Divider */} +
+ + {/* Public Share Option */} + + )} +
diff --git a/surfsense_web/components/public-chat/public-chat-header.tsx b/surfsense_web/components/public-chat/public-chat-header.tsx deleted file mode 100644 index 6f6e40a52..000000000 --- a/surfsense_web/components/public-chat/public-chat-header.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 index 1b7543712..8b21fede1 100644 --- a/surfsense_web/components/public-chat/public-chat-view.tsx +++ b/surfsense_web/components/public-chat/public-chat-view.tsx @@ -2,6 +2,7 @@ import { AssistantRuntimeProvider } from "@assistant-ui/react"; import { Loader2 } from "lucide-react"; +import { Navbar } from "@/components/homepage/navbar"; import { DisplayImageToolUI } from "@/components/tool-ui/display-image"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; @@ -9,7 +10,6 @@ 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 { @@ -22,37 +22,43 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) { if (isLoading) { return ( -
- -
+
+ +
+ +
+
); } if (error || !data) { return ( -
-

Chat not found

-

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

-
+
+ +
+

Chat not found

+

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

+
+
); } return ( - - {/* Tool UIs for rendering tool results */} - - - - +
+ + + {/* 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 index 2fe1ecff6..e88e5aae7 100644 --- a/surfsense_web/components/public-chat/public-thread.tsx +++ b/surfsense_web/components/public-chat/public-thread.tsx @@ -12,10 +12,8 @@ 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; } @@ -23,7 +21,7 @@ interface PublicThreadProps { * Read-only thread component for public chat viewing. * No composer, no edit capabilities - just message display. */ -export const PublicThread: FC = ({ header, footer }) => { +export const PublicThread: FC = ({ footer }) => { return ( = ({ header, footer }) => { }} > - {header} - { return ( diff --git a/surfsense_web/hooks/use-public-chat-runtime.ts b/surfsense_web/hooks/use-public-chat-runtime.ts index cc7e95fdc..2e79e0e1b 100644 --- a/surfsense_web/hooks/use-public-chat-runtime.ts +++ b/surfsense_web/hooks/use-public-chat-runtime.ts @@ -1,17 +1,31 @@ "use client"; -import { - type AppendMessage, - type ThreadMessageLike, - useExternalStoreRuntime, -} from "@assistant-ui/react"; +import { type AppendMessage, useExternalStoreRuntime } from "@assistant-ui/react"; import { useCallback, useMemo } from "react"; import type { GetPublicChatResponse, PublicChatMessage } from "@/contracts/types/public-chat.types"; +import { convertToThreadMessage } from "@/lib/chat/message-utils"; +import type { MessageRecord } from "@/lib/chat/thread-persistence"; interface UsePublicChatRuntimeOptions { data: GetPublicChatResponse | undefined; } +/** + * Map PublicChatMessage to MessageRecord shape for reuse of convertToThreadMessage + */ +function toMessageRecord(msg: PublicChatMessage, idx: number): MessageRecord { + return { + id: idx, + thread_id: 0, + role: msg.role as "user" | "assistant" | "system", + content: msg.content, + created_at: msg.created_at, + author_id: msg.author ? "public" : null, + author_display_name: msg.author?.display_name ?? null, + author_avatar_url: msg.author?.avatar_url ?? null, + }; +} + /** * Creates a read-only runtime for public chat viewing. */ @@ -21,24 +35,8 @@ export function usePublicChatRuntime({ data }: UsePublicChatRuntimeOptions) { // 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, - }), + (msg: PublicChatMessage, idx: number) => convertToThreadMessage(toMessageRecord(msg, idx)), [] ); diff --git a/surfsense_web/lib/chat/message-utils.ts b/surfsense_web/lib/chat/message-utils.ts new file mode 100644 index 000000000..868ed28eb --- /dev/null +++ b/surfsense_web/lib/chat/message-utils.ts @@ -0,0 +1,109 @@ +import type { ThreadMessageLike } from "@assistant-ui/react"; +import { z } from "zod"; +import type { MessageRecord } from "./thread-persistence"; + +/** + * Zod schema for persisted attachment info + */ +const PersistedAttachmentSchema = z.object({ + id: z.string(), + name: z.string(), + type: z.string(), + contentType: z.string().optional(), + imageDataUrl: z.string().optional(), + extractedContent: z.string().optional(), +}); + +const AttachmentsPartSchema = z.object({ + type: z.literal("attachments"), + items: z.array(PersistedAttachmentSchema), +}); + +type PersistedAttachment = z.infer; + +/** + * Extract persisted attachments from message content (type-safe with Zod) + */ +function extractPersistedAttachments(content: unknown): PersistedAttachment[] { + if (!Array.isArray(content)) return []; + + for (const part of content) { + const result = AttachmentsPartSchema.safeParse(part); + if (result.success) { + return result.data.items; + } + } + + return []; +} + +/** + * Convert backend message to assistant-ui ThreadMessageLike format + * Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps + * Restores attachments for user messages from persisted data + */ +export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike { + let content: ThreadMessageLike["content"]; + + if (typeof msg.content === "string") { + content = [{ type: "text", text: msg.content }]; + } else if (Array.isArray(msg.content)) { + // Filter out custom metadata parts - they're handled separately + const filteredContent = msg.content.filter((part: unknown) => { + if (typeof part !== "object" || part === null || !("type" in part)) return true; + const partType = (part as { type: string }).type; + // Filter out thinking-steps, mentioned-documents, and attachments + return ( + partType !== "thinking-steps" && + partType !== "mentioned-documents" && + partType !== "attachments" + ); + }); + content = + filteredContent.length > 0 + ? (filteredContent as ThreadMessageLike["content"]) + : [{ type: "text", text: "" }]; + } else { + content = [{ type: "text", text: String(msg.content) }]; + } + + // Restore attachments for user messages + let attachments: ThreadMessageLike["attachments"]; + if (msg.role === "user") { + const persistedAttachments = extractPersistedAttachments(msg.content); + if (persistedAttachments.length > 0) { + attachments = persistedAttachments.map((att) => ({ + id: att.id, + name: att.name, + type: att.type as "document" | "image" | "file", + contentType: att.contentType || "application/octet-stream", + status: { type: "complete" as const }, + content: [], + // Custom fields for our ChatAttachment interface + imageDataUrl: att.imageDataUrl, + extractedContent: att.extractedContent, + })); + } + } + + // Build metadata.custom for author display in shared chats + const metadata = msg.author_id + ? { + custom: { + author: { + displayName: msg.author_display_name ?? null, + avatarUrl: msg.author_avatar_url ?? null, + }, + }, + } + : undefined; + + return { + id: `msg-${msg.id}`, + role: msg.role, + content, + createdAt: new Date(msg.created_at), + attachments, + metadata, + }; +} diff --git a/surfsense_web/lib/chat/thread-persistence.ts b/surfsense_web/lib/chat/thread-persistence.ts index 08c08ba78..6990ff582 100644 --- a/surfsense_web/lib/chat/thread-persistence.ts +++ b/surfsense_web/lib/chat/thread-persistence.ts @@ -24,6 +24,7 @@ export interface ThreadRecord { created_at: string; updated_at: string; has_comments?: boolean; + public_share_enabled?: boolean; } export interface MessageRecord {