From bb5c8e49a8d5b24aeca9c873606a17dc0f72c7c5 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Mon, 1 Jun 2026 17:40:03 -0700 Subject: [PATCH 1/6] feat(migrations): fix automation tables migration for idempotency - Updated migration script to ensure ENUM types and tables are created only if they do not already exist, preventing errors on re-runs. - Added 'IF NOT EXISTS' clauses to table and index creation statements for improved safety during migrations. - Ensured that the migration can be safely re-applied without causing conflicts or failures. --- .../versions/144_add_automation_tables.py | 96 +++++++++++++------ 1 file changed, 66 insertions(+), 30 deletions(-) diff --git a/surfsense_backend/alembic/versions/144_add_automation_tables.py b/surfsense_backend/alembic/versions/144_add_automation_tables.py index 39f927417..296c33585 100644 --- a/surfsense_backend/alembic/versions/144_add_automation_tables.py +++ b/surfsense_backend/alembic/versions/144_add_automation_tables.py @@ -25,34 +25,60 @@ depends_on: str | Sequence[str] | None = None def upgrade() -> None: - # ENUM types (PostgreSQL requires types created before tables that use them) + # Guard every object so the migration is safe to re-run after a partial + # apply (the types/tables outlive a failed run that never advanced + # alembic_version). Types must precede the tables that reference them. op.execute( """ - CREATE TYPE automation_status AS ENUM ( - 'active', 'paused', 'archived' - ); + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'automation_status' + ) THEN + CREATE TYPE automation_status AS ENUM ( + 'active', 'paused', 'archived' + ); + END IF; + END + $$; """ ) op.execute( """ - CREATE TYPE automation_trigger_type AS ENUM ( - 'schedule', 'manual' - ); + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'automation_trigger_type' + ) THEN + CREATE TYPE automation_trigger_type AS ENUM ( + 'schedule', 'manual' + ); + END IF; + END + $$; """ ) op.execute( """ - CREATE TYPE automation_run_status AS ENUM ( - 'pending', 'running', 'succeeded', 'failed', - 'cancelled', 'timed_out' - ); + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'automation_run_status' + ) THEN + CREATE TYPE automation_run_status AS ENUM ( + 'pending', 'running', 'succeeded', 'failed', + 'cancelled', 'timed_out' + ); + END IF; + END + $$; """ ) # automations — the editable, versioned automation definition op.execute( """ - CREATE TABLE automations ( + CREATE TABLE IF NOT EXISTS automations ( id SERIAL PRIMARY KEY, search_space_id INTEGER NOT NULL REFERENCES searchspaces(id) ON DELETE CASCADE, @@ -69,19 +95,25 @@ def upgrade() -> None: """ ) op.execute( - "CREATE INDEX ix_automations_search_space_id ON automations(search_space_id);" + "CREATE INDEX IF NOT EXISTS ix_automations_search_space_id ON automations(search_space_id);" ) op.execute( - "CREATE INDEX ix_automations_created_by_user_id ON automations(created_by_user_id);" + "CREATE INDEX IF NOT EXISTS ix_automations_created_by_user_id ON automations(created_by_user_id);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_automations_status ON automations(status);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_automations_created_at ON automations(created_at);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_automations_updated_at ON automations(updated_at);" ) - op.execute("CREATE INDEX ix_automations_status ON automations(status);") - op.execute("CREATE INDEX ix_automations_created_at ON automations(created_at);") - op.execute("CREATE INDEX ix_automations_updated_at ON automations(updated_at);") # automation_triggers — one row per (automation, trigger-instance) pair op.execute( """ - CREATE TABLE automation_triggers ( + CREATE TABLE IF NOT EXISTS automation_triggers ( id SERIAL PRIMARY KEY, automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE, @@ -96,20 +128,22 @@ def upgrade() -> None: """ ) op.execute( - "CREATE INDEX ix_automation_triggers_automation_id ON automation_triggers(automation_id);" - ) - op.execute("CREATE INDEX ix_automation_triggers_type ON automation_triggers(type);") - op.execute( - "CREATE INDEX ix_automation_triggers_enabled ON automation_triggers(enabled);" + "CREATE INDEX IF NOT EXISTS ix_automation_triggers_automation_id ON automation_triggers(automation_id);" ) op.execute( - "CREATE INDEX ix_automation_triggers_created_at ON automation_triggers(created_at);" + "CREATE INDEX IF NOT EXISTS ix_automation_triggers_type ON automation_triggers(type);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_automation_triggers_enabled ON automation_triggers(enabled);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_automation_triggers_created_at ON automation_triggers(created_at);" ) # Partial index for the schedule tick: only enabled schedule triggers # with a scheduled next fire are ever scanned for due rows. op.execute( """ - CREATE INDEX ix_automation_triggers_due + CREATE INDEX IF NOT EXISTS ix_automation_triggers_due ON automation_triggers (next_fire_at) WHERE enabled = true AND type = 'schedule' @@ -120,7 +154,7 @@ def upgrade() -> None: # automation_runs — the immutable per-fire execution record op.execute( """ - CREATE TABLE automation_runs ( + CREATE TABLE IF NOT EXISTS automation_runs ( id SERIAL PRIMARY KEY, automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE, @@ -140,14 +174,16 @@ def upgrade() -> None: """ ) op.execute( - "CREATE INDEX ix_automation_runs_automation_id ON automation_runs(automation_id);" + "CREATE INDEX IF NOT EXISTS ix_automation_runs_automation_id ON automation_runs(automation_id);" ) op.execute( - "CREATE INDEX ix_automation_runs_trigger_id ON automation_runs(trigger_id);" + "CREATE INDEX IF NOT EXISTS ix_automation_runs_trigger_id ON automation_runs(trigger_id);" ) - op.execute("CREATE INDEX ix_automation_runs_status ON automation_runs(status);") op.execute( - "CREATE INDEX ix_automation_runs_created_at ON automation_runs(created_at);" + "CREATE INDEX IF NOT EXISTS ix_automation_runs_status ON automation_runs(status);" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_automation_runs_created_at ON automation_runs(created_at);" ) From 0bbeedda0782d5812f75fc665314c9d3d72055ad Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Mon, 1 Jun 2026 18:33:08 -0700 Subject: [PATCH 2/6] fix(route): update backend URL handling for internal Docker network - Modified backend URL assignment to ensure it resolves correctly within the internal Docker network, preventing 503 errors for authenticated Zero queries. - Added comments to clarify the routing behavior and the necessity of using the internal backend URL. --- surfsense_web/app/api/zero/query/route.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/surfsense_web/app/api/zero/query/route.ts b/surfsense_web/app/api/zero/query/route.ts index 0e64c932f..bff86c25e 100644 --- a/surfsense_web/app/api/zero/query/route.ts +++ b/surfsense_web/app/api/zero/query/route.ts @@ -6,7 +6,16 @@ import type { Context } from "@/types/zero"; import { queries } from "@/zero/queries"; import { schema } from "@/zero/schema"; -const backendURL = BACKEND_URL; +// This route is invoked server-to-server by zero-cache (via ZERO_QUERY_URL), +// so it must reach the backend over the internal Docker network +// (e.g. http://backend:8000). The browser-facing NEXT_PUBLIC_FASTAPI_BACKEND_URL +// (e.g. http://localhost:8929) does NOT resolve from inside the frontend +// container and would make every authenticated Zero query fail with a 503. +const backendURL = ( + process.env.FASTAPI_BACKEND_INTERNAL_URL || + BACKEND_URL || + "http://localhost:8000" +).replace(/\/$/, ""); async function authenticateRequest( request: Request From 66bd7e6fc300682810b1daca3d8bce60828dd5cb Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Mon, 1 Jun 2026 20:21:21 -0700 Subject: [PATCH 3/6] refactor(chat): enhance chat example prompts layout and accessibility - Updated TabsContent component to include focus-visible outline for better accessibility. - Adjusted ScrollArea height to improve visual consistency and responsiveness. - Increased padding in the list for better spacing and usability. --- .../components/new-chat/chat-example-prompts.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/surfsense_web/components/new-chat/chat-example-prompts.tsx b/surfsense_web/components/new-chat/chat-example-prompts.tsx index 95d7a0eaa..4a204386a 100644 --- a/surfsense_web/components/new-chat/chat-example-prompts.tsx +++ b/surfsense_web/components/new-chat/chat-example-prompts.tsx @@ -57,9 +57,13 @@ export function ChatExamplePrompts({ onSelect }: ChatExamplePromptsProps) { {CHAT_EXAMPLE_CATEGORIES.map((category) => ( - - -
    + + +
      {category.prompts.map((prompt) => (
    • From 087057176533bcf80e0682f74e89a1d38bc035ad Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Mon, 1 Jun 2026 20:34:18 -0700 Subject: [PATCH 4/6] refactor(thread): improve layout and responsiveness of welcome message - Adjusted the structure of the ThreadWelcome component to enhance layout consistency across different screen sizes. - Updated CSS classes to ensure proper alignment and spacing for the welcome message and composer, improving overall user experience. --- surfsense_web/components/assistant-ui/thread.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 5748b441c..dbc831b2f 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -254,13 +254,15 @@ const ThreadWelcome: FC = () => { return (
      -
      -

      - {greeting} -

      -
      -
      - +
      +
      +

      + {greeting} +

      +
      +
      + +
      ); From afbe6abaaf77dfa410149c6fab51b4c26545d58c Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 3 Jun 2026 16:11:33 -0700 Subject: [PATCH 5/6] refactor(page): remove AdSenseScript component from FreeHubPage - Eliminated the AdSenseScript import and its usage in the FreeHubPage component to streamline the code and improve performance. --- surfsense_web/app/(home)/free/layout.tsx | 18 ++++++++++++++++++ surfsense_web/app/(home)/free/page.tsx | 2 -- 2 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 surfsense_web/app/(home)/free/layout.tsx diff --git a/surfsense_web/app/(home)/free/layout.tsx b/surfsense_web/app/(home)/free/layout.tsx new file mode 100644 index 000000000..9447d34dd --- /dev/null +++ b/surfsense_web/app/(home)/free/layout.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; +import { AdSenseScript } from "@/components/ads/adsense-script"; + +/** + * Wraps the /free hub and all /free/[model_slug] subpages. Mounting + * here loads adsbygoogle.js across the entire /free route + * tree, which is what powers both the manual slots and AdSense + * Auto ads. Because the script lives here (not in the root layout), Auto ads + * is naturally scoped to /free and its subpages only. + */ +export default function FreeSectionLayout({ children }: { children: ReactNode }) { + return ( + <> + + {children} + + ); +} diff --git a/surfsense_web/app/(home)/free/page.tsx b/surfsense_web/app/(home)/free/page.tsx index 5cea9b6d2..89a4735ae 100644 --- a/surfsense_web/app/(home)/free/page.tsx +++ b/surfsense_web/app/(home)/free/page.tsx @@ -3,7 +3,6 @@ import type { Metadata } from "next"; import Link from "next/link"; import { AdUnit } from "@/components/ads/ad-unit"; import { ADSENSE_SLOTS } from "@/components/ads/adsense-config"; -import { AdSenseScript } from "@/components/ads/adsense-script"; import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav"; import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld"; import { Badge } from "@/components/ui/badge"; @@ -160,7 +159,6 @@ export default async function FreeHubPage() { return (
      - Date: Wed, 3 Jun 2026 17:52:40 -0700 Subject: [PATCH 6/6] feat(chat): add RemoveAdsBanner component to FreeChatPage - Integrated the RemoveAdsBanner component into the FreeChatPage to enhance user experience by providing ad-free interaction. --- .../components/free-chat/free-chat-page.tsx | 5 +- .../free-chat/remove-ads-banner.tsx | 77 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 surfsense_web/components/free-chat/remove-ads-banner.tsx diff --git a/surfsense_web/components/free-chat/free-chat-page.tsx b/surfsense_web/components/free-chat/free-chat-page.tsx index 2ee026cc3..b28b1e0a1 100644 --- a/surfsense_web/components/free-chat/free-chat-page.tsx +++ b/surfsense_web/components/free-chat/free-chat-page.tsx @@ -37,6 +37,7 @@ import { BACKEND_URL } from "@/lib/env-config"; import { trackAnonymousChatMessageSent } from "@/lib/posthog/events"; import { FreeModelSelector } from "./free-model-selector"; import { FreeThread } from "./free-thread"; +import { RemoveAdsBanner } from "./remove-ads-banner"; // Render all tool calls via ToolFallback; backend keeps persisted // payloads bounded by summarising / truncating outputs. @@ -135,7 +136,7 @@ export function FreeChatPage() { pendingRetryRef.current = null; }, [resetKey, modelSlug, tokenUsageStore]); - const cancelRun = useCallback(() => { + const cancelRun = useCallback(async () => { if (abortControllerRef.current) { abortControllerRef.current.abort(); abortControllerRef.current = null; @@ -487,6 +488,8 @@ export function FreeChatPage() {
      + + {captchaRequired && TURNSTILE_SITE_KEY && (
      diff --git a/surfsense_web/components/free-chat/remove-ads-banner.tsx b/surfsense_web/components/free-chat/remove-ads-banner.tsx new file mode 100644 index 000000000..143609c6b --- /dev/null +++ b/surfsense_web/components/free-chat/remove-ads-banner.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { Sparkles, X } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const ADSENSE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID; + +// Versioned key so the copy can change later without resurfacing for users who +// already dismissed an older variant (bump the version to re-show). +const DISMISS_KEY = "surfsense:remove-ads-banner-dismissed:v1"; + +/** + * Dismissible promo shown on the free /free/[model_slug] chat pages, nudging + * anonymous users to sign up to remove ads. Dismissal is persisted in + * localStorage so it stays hidden across reloads and navigations. The free + * chat keeps working whether or not the banner is dismissed. + * + * Renders nothing when AdSense is not configured (dev/preview), since there are + * no ads to remove in that case. + */ +export function RemoveAdsBanner({ className }: { className?: string }) { + // Default hidden so dismissed users never see a flash before the stored + // value is read on the client (avoids a hydration/flicker mismatch). + const [dismissed, setDismissed] = useState(true); + + useEffect(() => { + try { + setDismissed(localStorage.getItem(DISMISS_KEY) === "1"); + } catch { + // localStorage can throw in private browsing / when disabled. + setDismissed(false); + } + }, []); + + const handleDismiss = () => { + setDismissed(true); + try { + localStorage.setItem(DISMISS_KEY, "1"); + } catch { + // Ignore: dismissal just won't persist across reloads. + } + }; + + if (!ADSENSE_CLIENT_ID || dismissed) return null; + + return ( +
      + + + Go ad-free with a free account + +

      + Create a free SurfSense account to remove ads, unlock $5 of premium credit, and save + your chat history. You can keep chatting for free either way. +

      + +
      + +
      +
      + ); +}