diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 12bdea455..4ba12c171 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -15,6 +15,8 @@ from dataclasses import dataclass from typing import Any from uuid import UUID +import logging + from langchain_core.messages import HumanMessage from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select @@ -28,7 +30,7 @@ from app.agents.new_chat.llm_config import ( load_agent_config, load_llm_config_from_yaml, ) -from app.db import ChatVisibility, Document, Report, SurfsenseDocsDocument +from app.db import ChatVisibility, Document, Report, SurfsenseDocsDocument, async_session_maker from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE from app.services.chat_session_state_service import ( clear_ai_responding, @@ -1276,8 +1278,21 @@ async def stream_new_chat( yield streaming_service.format_done() finally: - # Clear AI responding state for live collaboration - await clear_ai_responding(session, chat_id) + # Clear AI responding state for live collaboration. + # The original session may be broken (client disconnect / CancelledError + # can corrupt the underlying DB connection), so we try a rollback first + # and fall back to a fresh session if the original is unusable. + try: + await session.rollback() + await clear_ai_responding(session, chat_id) + except Exception: + try: + async with async_session_maker() as fresh_session: + await clear_ai_responding(fresh_session, chat_id) + except Exception: + logging.getLogger(__name__).warning( + "Failed to clear AI responding state for thread %s", chat_id + ) async def stream_resume_chat( @@ -1397,4 +1412,14 @@ async def stream_resume_chat( yield streaming_service.format_done() finally: - await clear_ai_responding(session, chat_id) + try: + await session.rollback() + await clear_ai_responding(session, chat_id) + except Exception: + try: + async with async_session_maker() as fresh_session: + await clear_ai_responding(fresh_session, chat_id) + except Exception: + logging.getLogger(__name__).warning( + "Failed to clear AI responding state for thread %s", chat_id + ) diff --git a/surfsense_web/app/(home)/page.tsx b/surfsense_web/app/(home)/page.tsx index b5a726423..e0478fce3 100644 --- a/surfsense_web/app/(home)/page.tsx +++ b/surfsense_web/app/(home)/page.tsx @@ -5,13 +5,11 @@ import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid"; import { FeaturesCards } from "@/components/homepage/features-card"; import { HeroSection } from "@/components/homepage/hero-section"; import ExternalIntegrations from "@/components/homepage/integrations"; -import { UseCasesGrid } from "@/components/homepage/use-cases-grid"; export default function HomePage() { return (
- diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx index 9d0cd6f0c..00226517c 100644 --- a/surfsense_web/components/homepage/hero-section.tsx +++ b/surfsense_web/components/homepage/hero-section.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import type React from "react"; import { useEffect, useRef, useState } from "react"; import Balancer from "react-wrap-balancer"; -import { WalkthroughScroll } from "@/components/ui/walkthrough-scroll"; +import { HeroCarousel } from "@/components/ui/hero-carousel"; import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config"; import { trackLoginAttempt } from "@/lib/posthog/events"; import { cn } from "@/lib/utils"; @@ -97,18 +97,18 @@ export function HeroSection() { )} {/* // TODO:aCTUAL DESCRITION */} -

+

Connect any AI to your documents, Drive, Notion and more,

-

- then chat with it, invite your team, or generate podcasts and reports. +

+ then chat with it, generate podcasts and reports, or even invite your team.

{/* */}
-
- +
+
); diff --git a/surfsense_web/components/ui/expanded-gif-overlay.tsx b/surfsense_web/components/ui/expanded-gif-overlay.tsx index 56f343ded..1e4f622ba 100644 --- a/surfsense_web/components/ui/expanded-gif-overlay.tsx +++ b/surfsense_web/components/ui/expanded-gif-overlay.tsx @@ -2,6 +2,7 @@ import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; function ExpandedGifOverlay({ src, @@ -20,7 +21,7 @@ function ExpandedGifOverlay({ return () => document.removeEventListener("keydown", handleKey); }, [onClose]); - return ( + return createPortal( e.stopPropagation()} + className="max-h-[90vh] max-w-[90vw] cursor-pointer rounded-2xl shadow-2xl" /> - + , + document.body, ); } diff --git a/surfsense_web/components/ui/hero-carousel.tsx b/surfsense_web/components/ui/hero-carousel.tsx new file mode 100644 index 000000000..c623aa525 --- /dev/null +++ b/surfsense_web/components/ui/hero-carousel.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay"; + +const carouselItems = [ + { + title: "Connect & Sync", + description: + "Connect data sources like Notion, Drive and Gmail. Automatically sync to keep them updated.", + src: "/homepage/hero_tutorial/ConnectorFlowGif.gif", + }, + { + title: "Upload Documents", + description: "Upload documents directly, from images to massive PDFs.", + src: "/homepage/hero_tutorial/DocUploadGif.gif", + }, + { + title: "Search & Citation", + description: + "Ask questions and get cited responses from your knowledge base.", + src: "/homepage/hero_tutorial/BSNCGif.gif", + }, + { + title: "Targeted Document Q&A", + description: "Mention specific documents in chat for targeted answers.", + src: "/homepage/hero_tutorial/BQnaGif_compressed.gif", + }, + { + title: "Produce Reports Instantly", + description: "Generate reports from your sources in many formats.", + src: "/homepage/hero_tutorial/ReportGenGif_compressed.gif", + }, + { + title: "Create Podcasts", + description: "Turn anything into a podcast in under 20 seconds.", + src: "/homepage/hero_tutorial/PodcastGenGif.gif", + }, + { + title: "Image Generation", + description: "Generate high-quality images easily from your conversations.", + src: "/homepage/hero_tutorial/ImageGenGif.gif", + }, + { + title: "Collaborative AI Chat", + description: "Collaborate on AI-powered conversations in realtime with your team.", + src: "/homepage/hero_realtime/RealTimeChatGif.gif", + }, + { + title: "Realtime Comments", + description: "Add comments and tag teammates on any message.", + src: "/homepage/hero_realtime/RealTimeCommentsFlow.gif", + }, +]; + +function HeroCarouselCard({ + index, + title, + description, + src, + isActive, + onExpandedChange, +}: { + index: number; + title: string; + description: string; + src: string; + isActive: boolean; + onExpandedChange?: (expanded: boolean) => void; +}) { + const { expanded, open, close } = useExpandedGif(); + + useEffect(() => { + onExpandedChange?.(expanded); + }, [expanded, onExpandedChange]); + const imgRef = useRef(null); + const [frozenFrame, setFrozenFrame] = useState(null); + const [playKey, setPlayKey] = useState(0); + + const captureFrame = useCallback((img: HTMLImageElement) => { + try { + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + canvas.getContext("2d")?.drawImage(img, 0, 0); + setFrozenFrame(canvas.toDataURL()); + } catch { + /* cross-origin or other issue */ + } + }, []); + + useEffect(() => { + if (isActive) { + setPlayKey((k) => k + 1); + setFrozenFrame(null); + } else { + const img = imgRef.current; + if (img && img.complete && img.naturalWidth > 0) { + captureFrame(img); + } + } + }, [isActive, captureFrame]); + + useEffect(() => { + if (!isActive && !frozenFrame) { + const img = new Image(); + img.onload = () => captureFrame(img); + img.src = src; + } + }, [isActive, frozenFrame, src, captureFrame]); + + return ( + <> +
+
+ {/* + {index + 1} + */} +
+

+ {title} +

+

+ {description} +

+
+
+
+ {isActive ? ( + {title} + ) : frozenFrame ? ( + {title} + ) : ( +
+ )} +
+
+ + + {expanded && } + + + ); +} + +function HeroCarousel() { + const [activeIndex, setActiveIndex] = useState(0); + const [isPaused, setIsPaused] = useState(false); + const [isGifExpanded, setIsGifExpanded] = useState(false); + const [containerWidth, setContainerWidth] = useState(0); + const [cardHeight, setCardHeight] = useState(420); + const containerRef = useRef(null); + const activeCardRef = useRef(null); + const directionRef = useRef<"forward" | "backward">("forward"); + + const goTo = useCallback( + (newIndex: number) => { + directionRef.current = newIndex >= activeIndex ? "forward" : "backward"; + setActiveIndex(newIndex); + }, + [activeIndex], + ); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const update = () => setContainerWidth(el.offsetWidth); + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const el = activeCardRef.current; + if (!el) return; + const update = () => setCardHeight(el.offsetHeight); + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, [activeIndex, containerWidth]); + + useEffect(() => { + if (isPaused || isGifExpanded) return; + const timer = setTimeout(() => { + directionRef.current = "forward"; + setActiveIndex((prev) => (prev >= carouselItems.length - 1 ? 0 : prev + 1)); + }, 8000); + return () => clearTimeout(timer); + }, [activeIndex, isPaused, isGifExpanded]); + + const cardWidth = + containerWidth < 640 + ? containerWidth * 0.85 + : containerWidth < 1024 + ? Math.min(containerWidth * 0.7, 680) + : Math.min(containerWidth * 0.55, 900); + + const baseOffset = + containerWidth < 640 + ? containerWidth * 0.2 + : containerWidth < 1024 + ? containerWidth * 0.15 + : 150; + + const stackGap = containerWidth < 640 ? 35 : containerWidth < 1024 ? 45 : 55; + const perspective = containerWidth < 640 ? 800 : containerWidth < 1024 ? 1000 : 1200; + + const getCardStyle = useCallback( + (index: number) => { + const diff = index - activeIndex; + + if (diff === 0) { + const originX = directionRef.current === "forward" ? 1 : 0; + return { x: -cardWidth / 2, rotateY: 0, zIndex: 20, originX, overlayOpacity: 0, blur: 0 }; + } + + const dist = Math.abs(diff); + const isLeft = diff < 0; + const offset = baseOffset + (dist - 1) * stackGap; + const t = Math.min(1, dist / 3); + + return { + x: -cardWidth / 2 + (isLeft ? -offset : offset), + rotateY: isLeft ? 90 : -90, + zIndex: 20 - dist, + originX: isLeft ? 0 : 1, + overlayOpacity: t, + blur: t * 6, + }; + }, + [activeIndex, cardWidth, baseOffset, stackGap], + ); + + return ( +
+
setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + onTouchStart={() => setIsPaused(true)} + onTouchEnd={() => setIsPaused(false)} + > +
+ {containerWidth > 0 && + carouselItems.map((item, i) => { + const style = getCardStyle(i); + return ( + goTo(i) : undefined} + animate={{ + x: style.x, + rotateY: style.rotateY, + }} + transition={{ duration: 0.7, ease: [0.32, 0.72, 0, 1] }} + > + + + + + + ); + })} +
+
+ +
+ {carouselItems.map((_, i) => ( +
+
+ ); +} + +export { HeroCarousel, HeroCarouselCard }; diff --git a/surfsense_web/components/ui/walkthrough-scroll.tsx b/surfsense_web/components/ui/walkthrough-scroll.tsx deleted file mode 100644 index c14aa968f..000000000 --- a/surfsense_web/components/ui/walkthrough-scroll.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -import { AnimatePresence, motion, useScroll, useTransform } from "motion/react"; -import { useRef } from "react"; -import { ExpandedGifOverlay, useExpandedGif } from "@/components/ui/expanded-gif-overlay"; - -const walkthroughSteps = [ - { - step: 1, - title: "Login", - description: "Login to get started.", - src: "/homepage/hero_tutorial/LoginFlowGif.gif", - }, - { - step: 2, - title: "Connect & Sync", - description: "Connect your connectors and sync. Enable periodic syncing to keep them updated.", - src: "/homepage/hero_tutorial/ConnectorFlowGif.gif", - }, - { - step: 3, - title: "Upload Documents", - description: "While connectors index, upload your documents directly.", - src: "/homepage/hero_tutorial/DocUploadGif.gif", - }, -]; - -function WalkthroughCard({ - i, - step, - title, - description, - src, - progress, - range, - targetScale, -}: { - i: number; - step: number; - title: string; - description: string; - src: string; - progress: ReturnType["scrollYProgress"]; - range: [number, number]; - targetScale: number; -}) { - const container = useRef(null); - const scale = useTransform(progress, range, [1, targetScale]); - const { expanded, open, close } = useExpandedGif(); - - return ( - <> -
- -
- - {step} - -
-

- {title} -

-

- {description} -

-
-
-
- {title} -
-
-
- - - {expanded && } - - - ); -} - -function WalkthroughScroll() { - const container = useRef(null); - const { scrollYProgress } = useScroll({ - target: container, - offset: ["start start", "end end"], - }); - - return ( -
- {walkthroughSteps.map((project, i) => { - const targetScale = Math.max(0.6, 1 - (walkthroughSteps.length - i - 1) * 0.05); - return ( - - ); - })} -
- ); -} - -export { WalkthroughScroll, WalkthroughCard };