Merge remote-tracking branch 'upstream/dev' into fix/changelogs

This commit is contained in:
Anish Sarkar 2026-06-09 11:06:52 +05:30
commit 1c3f4cc6ac
28 changed files with 2461 additions and 785 deletions

View file

@ -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
* <AdSenseScript /> here loads adsbygoogle.js across the entire /free route
* tree, which is what powers both the manual <AdUnit /> 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 (
<>
<AdSenseScript />
{children}
</>
);
}

View file

@ -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 { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@ -159,7 +158,6 @@ export default async function FreeHubPage() {
return (
<div className="min-h-screen pt-20">
<AdSenseScript />
<JsonLd
data={{
"@context": "https://schema.org",

View file

@ -1,12 +1,20 @@
import { mustGetQuery } from "@rocicorp/zero";
import { handleQueryRequest } from "@rocicorp/zero/server";
import { NextResponse } from "next/server";
import { BACKEND_URL } from "@/lib/env-config";
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

View file

@ -21,6 +21,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { useFolderSync } from "@/hooks/use-folder-sync";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { useElectronAPI } from "@/hooks/use-platform";
import { isLlmOnboardingComplete } from "@/lib/onboarding";
export function DashboardClientLayout({
children,
@ -47,9 +48,8 @@ export function DashboardClientLayout({
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
const isOnboardingComplete = useCallback(() => {
// Check that the Agent LLM ID is set, including 0 for Auto mode.
return preferences.agent_llm_id !== null && preferences.agent_llm_id !== undefined;
}, [preferences.agent_llm_id]);
return isLlmOnboardingComplete(preferences.agent_llm_id, globalConfigs.length > 0);
}, [preferences.agent_llm_id, globalConfigs.length]);
const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom);
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);

View file

@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { isLlmOnboardingComplete } from "@/lib/onboarding";
export default function OnboardPage() {
const router = useRouter();
@ -52,15 +53,16 @@ export default function OnboardPage() {
}
}, []);
// Check if onboarding is already complete (including 0 for Auto mode)
const isOnboardingComplete =
preferences.agent_llm_id !== null && preferences.agent_llm_id !== undefined;
const isOnboardingComplete = isLlmOnboardingComplete(
preferences.agent_llm_id,
globalConfigs.length > 0
);
useEffect(() => {
if (!preferencesLoading && isOnboardingComplete) {
if (!preferencesLoading && globalConfigsLoaded && isOnboardingComplete) {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
}, [preferencesLoading, isOnboardingComplete, router, searchSpaceId]);
}, [preferencesLoading, globalConfigsLoaded, isOnboardingComplete, router, searchSpaceId]);
useEffect(() => {
const autoConfigureWithGlobal = async () => {

View file

@ -254,13 +254,15 @@ const ThreadWelcome: FC = () => {
return (
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-[2.625rem] select-none">
{greeting}
</h1>
</div>
<div className="w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
<Composer />
<div className="my-auto flex w-full flex-col items-center gap-6 py-6 sm:contents sm:my-0 sm:gap-0 sm:py-0">
<div className="aui-thread-welcome-message flex flex-col items-center text-center sm:absolute sm:bottom-[calc(50%+5rem)] sm:left-0 sm:right-0">
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-[2.625rem] select-none">
{greeting}
</h1>
</div>
<div className="w-full flex items-start justify-center sm:absolute sm:top-[calc(50%-3.5rem)] sm:left-0 sm:right-0">
<Composer />
</div>
</div>
</div>
);

View file

@ -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() {
<FreeModelSelector />
</div>
<RemoveAdsBanner />
{captchaRequired && TURNSTILE_SITE_KEY && (
<div className="flex justify-center border-b bg-muted/30 px-4 py-4">
<Alert className="w-auto max-w-md">

View file

@ -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 (
<div className={cn("shrink-0 border-b bg-muted/30 px-4 py-3", className)}>
<Alert className="relative mx-auto w-full max-w-2xl pr-10">
<Sparkles />
<AlertTitle>Go ad-free with a free account</AlertTitle>
<AlertDescription>
<p>
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.
</p>
<Button asChild size="sm" className="mt-1">
<Link href="/login">Create Free Account</Link>
</Button>
</AlertDescription>
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleDismiss}
aria-label="Dismiss"
className="absolute top-2 right-2 size-6"
>
<X />
</Button>
</Alert>
</div>
);
}

View file

@ -76,36 +76,24 @@ export function ChatExamplePrompts({ onSelect }: ChatExamplePromptsProps) {
})}
</div>
</div>
)}
{activeCategory ? (
<div className="overflow-hidden rounded-lg border border-input bg-muted shadow-sm shadow-black/5 dark:shadow-black/10 sm:rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-foreground sm:text-sm">
<span className="truncate">{activeCategory.label}</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setActiveCategoryId(null)}
aria-label="Close example prompts"
className="size-7 shrink-0 rounded-full text-muted-foreground hover:bg-foreground/10 hover:text-foreground sm:size-8"
>
<X aria-hidden="true" className="size-3.5 sm:size-4" />
</Button>
</div>
<ScrollArea className="max-h-52 sm:max-h-64">
<ul className="divide-y px-2 pb-2 sm:px-3 sm:pb-3">
{activeCategory.prompts.map((prompt) => (
<li key={prompt} className="py-0.5 sm:py-1">
<ExamplePromptButton prompt={prompt} onSelect={onSelect} />
</li>
))}
</ul>
</ScrollArea>
</div>
) : null}
{CHAT_EXAMPLE_CATEGORIES.map((category) => (
<TabsContent
key={category.id}
value={category.id}
className="mt-3 focus-visible:outline-none"
>
<ScrollArea className="h-[clamp(7.5rem,26vh,12rem)]">
<ul className="flex flex-col gap-2 pr-3">
{category.prompts.map((prompt) => (
<li key={prompt}>
<ExamplePromptButton prompt={prompt} onSelect={onSelect} />
</li>
))}
</ul>
</ScrollArea>
</TabsContent>
))}
</Tabs>
</div>
);
}

View file

@ -35,6 +35,7 @@ All configuration lives in a single `docker/.env` file (or `surfsense/.env` if y
| Variable | Description | Default |
|----------|-------------|---------|
| `SURFSENSE_VERSION` | Image tag to deploy. Use `latest`, a clean version (e.g. `0.0.14`), or a specific build (e.g. `0.0.14.1`) | `latest` |
| `SURFSENSE_VARIANT` | Backend image variant. Leave empty for CPU, set `cuda` for CUDA 12.8, or `cuda126` for CUDA 12.6. | *(empty)* |
| `AUTH_TYPE` | Authentication method: `LOCAL` (email/password) or `GOOGLE` (OAuth) | `LOCAL` |
| `ETL_SERVICE` | Document parsing: `DOCLING` (local), `UNSTRUCTURED`, or `LLAMACLOUD` | `DOCLING` |
| `EMBEDDING_MODEL` | Embedding model for vector search | `sentence-transformers/all-MiniLM-L6-v2` |
@ -42,6 +43,62 @@ All configuration lives in a single `docker/.env` file (or `surfsense/.env` if y
| `STT_SERVICE` | Speech-to-text provider for audio files | `local/base` |
| `REGISTRATION_ENABLED` | Allow new user registrations | `TRUE` |
### Image Variants
SurfSense publishes CPU and CUDA backend image variants. The frontend image is not variant-specific.
| Backend tag | Use case | `SURFSENSE_VARIANT` |
|-------------|----------|---------------------|
| `:latest` | CPU-only default | *(empty)* |
| `:latest-cuda` | NVIDIA CUDA 12.8 backend image | `cuda` |
| `:latest-cuda126` | NVIDIA CUDA 12.6 backend image for older driver stacks | `cuda126` |
All backend variants are published for `linux/amd64` and `linux/arm64`. CUDA on `linux/arm64` is best-effort.
<Callout type="info">
GPU acceleration needs two settings: `SURFSENSE_VARIANT` selects the CUDA image, and `COMPOSE_FILE` enables the GPU device overlay. The host must have the NVIDIA Container Toolkit installed.
</Callout>
### NVIDIA GPU Acceleration
For most NVIDIA systems, add these values to `.env` to use the CUDA 12.8 image:
```dotenv
SURFSENSE_VARIANT=cuda
COMPOSE_FILE=docker-compose.yml:docker-compose.gpu.yml
SURFSENSE_GPU_COUNT=1
```
Use `SURFSENSE_VARIANT=cuda126` for older NVIDIA driver stacks or older GPUs that need the CUDA 12.6 fallback image.
On Windows, use `;` instead of `:` in `COMPOSE_FILE` inside `.env`:
```dotenv
COMPOSE_FILE=docker-compose.yml;docker-compose.gpu.yml
```
To switch variants later, edit `SURFSENSE_VARIANT` and `COMPOSE_FILE` in `.env`, then run:
```bash
docker compose pull
docker compose up -d --wait
```
### Automatic Updates
Manual Docker Compose installs do not start Watchtower automatically. To enable external automatic updates, run Watchtower separately:
```bash
docker run -d --name watchtower \
--restart unless-stopped \
-v /var/run/docker.sock:/var/run/docker.sock \
nickfedor/watchtower \
--label-enable \
--interval 86400
```
SurfSense containers are labeled for Watchtower, so `--label-enable` limits updates to the SurfSense services.
### Ports
| Variable | Description | Default |
@ -270,11 +327,13 @@ Symptom (in `docker compose logs zero-cache`):
Error: Unknown or invalid publications. Specified: [zero_publication]. Found: []
```
This means `zero-cache` started before `zero_publication` was created. With
the current compose files this should be impossible. The `migrations`
service blocks `zero-cache` from starting. If you see it, your stack
predates the fix or you brought up `zero-cache` manually with `docker
compose up zero-cache` before the migrations service ran.
This means `zero-cache` started before `zero_publication` was created or the
publication does not match SurfSense's canonical Zero shape. With the current
compose files this should be impossible: the `migrations` service blocks
`zero-cache` from starting and verifies the publication before exiting
successfully. If you see it, your stack predates the fix or you brought up
`zero-cache` manually with `docker compose up zero-cache` before the migrations
service ran.
Recovery:
@ -284,18 +343,13 @@ docker volume rm surfsense-zero-cache # wipe half-built SQLite replica
docker compose up -d # migrations runs first, then zero-cache
```
The install script (`install.ps1` / `install.sh`) detects this case
automatically: if it finds a `surfsense-zero-cache` volume from a previous
install with no matching `surfsense-zero-init` volume, it removes the stale
volume before bringing the stack up.
### Zero-cache crashes with `_zero.tableMetadata` errors
This indicates a half-initialized SQLite replica left behind by a previous
crash. The `migrations` service writes a marker file on a shared volume
(`surfsense-zero-init`) when the publication oid changes; zero-cache wipes
its replica and re-syncs on next start. If the marker mechanism somehow did
not trigger, run the recovery one-liner above.
crash. Zero's own event triggers and `ZERO_AUTO_RESET` handle schema and
replication halts automatically. If the local SQLite replica is wedged, run the
recovery one-liner above to wipe `surfsense-zero-cache`; zero-cache will
re-sync from Postgres on the next start.
### Ensuring `wal_level = logical`

View file

@ -3,7 +3,7 @@ title: One-Line Install Script
description: One-command installation of SurfSense using Docker
---
Downloads the compose files, generates a `SECRET_KEY`, starts all services, and sets up [Watchtower](https://github.com/nicholas-fedor/watchtower) for automatic daily updates.
Downloads the compose files, generates a `SECRET_KEY`, starts all services with `docker compose up -d --wait`, and starts [Watchtower](https://github.com/nicholas-fedor/watchtower) as an external updater for automatic daily updates.
**Prerequisites:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) must be installed and running.
@ -19,9 +19,38 @@ curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scr
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
```
This creates a `./surfsense/` directory with `docker-compose.yml` and `.env`, then runs `docker compose up -d`.
This creates a `./surfsense/` directory with `docker-compose.yml`, `docker-compose.gpu.yml`, and `.env`, then runs `docker compose up -d --wait`.
To skip Watchtower (e.g. in production where you manage updates yourself):
If an NVIDIA GPU and NVIDIA Container Toolkit are detected, the installer asks whether to use GPU acceleration and chooses the compatible backend image automatically. Non-interactive installs default to CPU unless you pass an explicit flag.
Interactive installs also ask whether to enable automatic daily updates with Watchtower, noting that updates may download several GB in the background.
### GPU options
Linux/macOS:
```bash
# CUDA 12.8
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash -s -- --variant=cuda
# CUDA 12.6 fallback for older driver stacks
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash -s -- --variant=cuda126
# Reserve all available GPUs
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash -s -- --gpu --gpu-count=all
```
PowerShell:
```powershell
# Save the script locally first when passing PowerShell parameters.
.\install.ps1 -Variant cuda
.\install.ps1 -Variant cuda126 -GpuCount all
```
The installer writes the same `.env` settings you would configure manually: `SURFSENSE_VARIANT` selects the backend image and `COMPOSE_FILE` enables the GPU overlay.
To skip Watchtower (e.g. in production where you manage updates yourself, or to avoid large background image downloads):
```bash
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash -s -- --no-watchtower
@ -29,6 +58,16 @@ curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scr
To customise the check interval (default 24h), use `--watchtower-interval=SECONDS`.
Manual updates use the same compose state stored in `.env`, so GPU overlays and variants are preserved:
```bash
cd surfsense
docker compose pull
docker compose up -d --wait
```
If Watchtower is enabled, it preserves the running image variant tag automatically. Because SurfSense images are large, use `--no-watchtower` when you prefer to manage update timing yourself.
---
## Access SurfSense

View file

@ -0,0 +1,8 @@
export function isLlmOnboardingComplete(
agentLlmId: number | null | undefined,
hasGlobalConfigs: boolean
): boolean {
if (agentLlmId === null || agentLlmId === undefined) return false;
if (agentLlmId === 0) return hasGlobalConfigs;
return true;
}