Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,11 @@ Run the Python demo commands from `backend/` after migrations are current.
That gives you two visible loops: public-safe cited chat/search for viewers, plus capture ->
feedback review -> eval export/gate for local development.

For a zero-dollar hosted portfolio link, use the static Netlify build described in
[docs/portfolio-demo-netlify.md](docs/portfolio-demo-netlify.md). It serves public-safe fixture
data only, keeps all writes read-only, and does not deploy the backend, database, Redis, Gemini
key, API token, admin token, or private notes.

## Tech Stack

| Layer | Choice |
Expand Down
20 changes: 20 additions & 0 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@ Legend: ⬜ not started · 🟡 in progress · ✅ complete

Add a dated entry per working session. Most recent on top.

### 2026-06-08 - Netlify static portfolio demo
- **What:** added a separate static portfolio-demo path for Netlify using
`NEXT_PUBLIC_SECOND_BRAIN_DEMO_MODE=static`. The frontend now switches to a browser-side
public-safe demo API adapter only in static mode, while normal local/API-backed use remains the
default.
- **Demo behavior:** chat, streaming chat, search, status, sources, feedback, briefing, tasks,
research, and admin previews use fixture data that mirrors the public demo corpus. Mutating
flows return read-only errors instead of calling a backend.
- **Access/cost posture:** added an optional SHA-256 passcode gate for casual access control and
a root `netlify.toml` that builds `frontend/out` with no backend, database, Redis, Gemini key,
API token, admin token, or private notes deployed.
- **Docs:** added `docs/portfolio-demo-netlify.md` with Netlify setup, passcode-hash generation,
verification steps, and secret-exclusion guidance.
- **Review follow-up:** addressed CodeRabbit's static-demo review by restoring the non-demo `/`
redirect to `/chat`, pinning Turbopack root to the frontend config directory, extracting API
client contracts to a type-only module, making the demo stream delay SSR-safe, clarifying the
Netlify runbook, and adding a static-demo CSP header.
- **Verified:** reran frontend lint, normal `npm run build`, static demo export build, `git diff
--check`, and a static HTTP smoke for `/` plus `/chat/`.

### 2026-06-07 - README Gemini API switch guidance
- **What:** added README guidance explaining that LLM provider switching happens in the backend,
not the frontend, and documented the `backend/.env` variables for moving from the keyless
Expand Down
17 changes: 17 additions & 0 deletions docs/implementation-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ what I gave up**. Keep it honest — the surprises are the valuable part.

---

## Static Netlify demo uses browser fixtures instead of hosted backend (2026-06-08)

- **What:** added `NEXT_PUBLIC_SECOND_BRAIN_DEMO_MODE=static`, a static demo API adapter, a
public-safe fixture corpus, an optional browser-side passcode gate, and a `netlify.toml` that
exports the frontend to `frontend/out`.
- **Why:** the portfolio needs an always-available demo link with zero recurring infrastructure
cost and limited casual access, without deploying private notes, Postgres, Redis, API tokens,
admin tokens, or Gemini credentials.
- **Trade-off / what I gave up:** the hosted Netlify demo is not the live RAG backend. It is a
faithful read-only UI preview with deterministic cited answers and static operational data. The
full FastAPI/Postgres/MCP/eval workflow remains the local or short-lived backend demo path.
- **Affects:** `frontend/lib/demo/*`, `frontend/lib/api/{client.ts,demo-client.ts}`,
`frontend/components/{DemoAccessGate.tsx,Providers.tsx,ConversationSidebar.tsx}`,
`frontend/next.config.ts`, `netlify.toml`, `docs/portfolio-demo-netlify.md`, `README.md`.

---

## Public demo uses seeded corpus before anonymous uploads (2026-06-07)

- **What:** added `python -m app.demo.seed_public` as a separate seed path for a small public-safe
Expand Down
101 changes: 101 additions & 0 deletions docs/portfolio-demo-netlify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# Netlify Static Portfolio Demo

This runbook publishes a zero-dollar, always-available portfolio preview of Second Brain as a static Netlify site. It is separate from the normal local-first app: no backend, database, Redis, Gemini key, API token, admin token, or private notes are deployed.

## What The Static Demo Shows

- WattVision web shell, navigation, chat, search, status, sources, feedback, briefing, tasks, research, and admin surfaces.
- Public-safe fixture corpus matching `python -m app.demo.seed_public`.
- Deterministic cited answers for regular RAG and Agentic RAG demo prompts.
- Read-only behavior for capture, ingest, source edits, task creation, research enqueue, eval promotion, deletion, and retention purge.
- Optional browser-side passcode gate for casual access control.

## Important Access Boundary

`NEXT_PUBLIC_DEMO_ACCESS_HASH` is shipped to the browser because this is a static site. It limits casual visitors but is not a security boundary. Only public-safe content belongs in this demo.

## Netlify Site Setup

1. Push this branch to GitHub.
2. In Netlify, choose **Add new site** -> **Import an existing project**.
3. Select the GitHub repository.
4. Use the settings already committed in `netlify.toml`:

```toml
[build]
base = "frontend"
command = "npm ci && npm run build"
publish = "out"
```

5. Add this environment variable in Netlify:

```text
NEXT_PUBLIC_DEMO_ACCESS_HASH=<lowercase sha256 hash of your demo passcode>
```

If you omit `NEXT_PUBLIC_DEMO_ACCESS_HASH`, the passcode gate is disabled and all visitors get immediate access. Only add this variable when you want casual access control for the static demo.

Netlify already receives these from `netlify.toml`:

```text
NEXT_PUBLIC_SECOND_BRAIN_DEMO_MODE=static
NEXT_PUBLIC_AGENTIC_RAG_ENABLED=true
NEXT_TELEMETRY_DISABLED=1
```

Do not add these to Netlify for the static demo:

```text
DATABASE_URL
SECOND_BRAIN_API_TOKEN
SECOND_BRAIN_ADMIN_TOKEN
GEMINI_API_KEY
SECOND_BRAIN_TEST_DATABASE_URL
```

## Generate The Passcode Hash

Run this locally in PowerShell. Replace the example passcode before using it.

```powershell
$passcode = "replace-with-your-demo-passcode"
$bytes = [System.Text.Encoding]::UTF8.GetBytes($passcode)
$hash = [System.Security.Cryptography.SHA256]::HashData($bytes)
($hash | ForEach-Object { $_.ToString("x2") }) -join ""
```

Paste only the resulting hash into Netlify as `NEXT_PUBLIC_DEMO_ACCESS_HASH`.

## Local Verification

From `frontend/`:

```powershell
npm ci
npm run lint
# Verify the normal app build still works before enabling static demo export.
npm run build
$env:NEXT_PUBLIC_SECOND_BRAIN_DEMO_MODE="static"
$env:NEXT_PUBLIC_AGENTIC_RAG_ENABLED="true"
# Build the Netlify static export.
npm run build
```

The static build should create `frontend/out`. Preview it with any static file server, for example:

```powershell
python -m http.server 4173 --directory out
```

Open `http://localhost:4173/chat/` and verify:

- `/chat/` answers the suggested prompts with citations.
- `/search/` returns fixture corpus hits.
- `/sources/` shows one public demo source and seven documents.
- `/status/` reports `static-demo` runtime and no MCP mutations.
- write operations show read-only errors instead of calling a backend.

## Portfolio Link

After Netlify deploys successfully, add the live-demo URL to your personal portfolio site and to `README.md` under a clearly labeled `Live Demo` or `Deployment` section. Keep the repository link immediately adjacent, for example: `Live demo: <Netlify URL> (Repo: <repo URL>)`. Verify the live demo link is reachable after deployment.
7 changes: 7 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# Copy to frontend/.env.local for local development.
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
NEXT_PUBLIC_AGENTIC_RAG_ENABLED=false

# Static portfolio demo mode. Leave unset for normal local/API-backed use.
# Netlify should set NEXT_PUBLIC_SECOND_BRAIN_DEMO_MODE=static.
# Optional: set NEXT_PUBLIC_DEMO_ACCESS_HASH to the lowercase SHA-256 hash of
# the casual-access passcode. Do not commit the plain passcode.
NEXT_PUBLIC_SECOND_BRAIN_DEMO_MODE=
NEXT_PUBLIC_DEMO_ACCESS_HASH=
28 changes: 27 additions & 1 deletion frontend/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { STATIC_DEMO_MODE } from "@/lib/demo/config";

export default function Home() {
redirect("/chat");
if (!STATIC_DEMO_MODE) {
redirect("/chat");
}

return (
<div className="flex h-full items-center justify-center bg-background px-4 text-foreground">
<section className="w-full max-w-md rounded-lg border border-border bg-card p-5 text-center">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
Second Brain
</p>
<h1 className="mt-2 text-xl font-semibold text-foreground">
Open the workspace
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
Continue to the chat workspace for cited answers over the public-safe demo corpus.
</p>
<Link
href="/chat"
className="mt-5 inline-flex h-10 items-center justify-center rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-primary/25"
>
Open chat
</Link>
</section>
</div>
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
26 changes: 24 additions & 2 deletions frontend/components/ConversationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
} from "@phosphor-icons/react";

import { api, getStoredApiToken, setStoredApiToken } from "@/lib/api/client";
import { STATIC_DEMO_MODE } from "@/lib/demo/config";
import { queryClient } from "@/lib/query-client";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -136,7 +137,7 @@ function SidebarContent({ onNavigate, onClose }: { onNavigate?: () => void; onCl
Second Brain
</span>
<span className="block truncate text-[11px] leading-4 text-muted-foreground">
Local knowledge workspace
{STATIC_DEMO_MODE ? "Static portfolio demo" : "Local knowledge workspace"}
</span>
</span>
</Link>
Expand Down Expand Up @@ -206,6 +207,24 @@ function SidebarContent({ onNavigate, onClose }: { onNavigate?: () => void; onCl
))}
</nav>

{STATIC_DEMO_MODE ? (
<div className="mx-3 mb-3 mt-auto rounded-lg border border-primary/25 bg-primary/10 p-3">
<div className="flex items-start gap-2">
<ShieldCheck size={15} weight="bold" className="mt-0.5 shrink-0 text-primary" />
<div className="min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-xs font-semibold text-foreground">Static demo</p>
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground ring-1 ring-border">
read-only
</span>
</div>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
Public-safe fixtures power chat, search, sources, and status. Writes stay local-only.
</p>
</div>
</div>
</div>
) : (
<div className="mx-3 mb-3 mt-auto rounded-lg border border-border bg-background p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
Expand Down Expand Up @@ -257,6 +276,7 @@ function SidebarContent({ onNavigate, onClose }: { onNavigate?: () => void; onCl
</button>
</form>
</div>
)}
</div>
);
}
Expand Down Expand Up @@ -386,7 +406,9 @@ function MobileTopBar({ onOpen }: { onOpen: () => void }) {
</button>
<Link href="/chat" className="min-w-0 text-center">
<span className="block text-sm font-semibold leading-4 text-foreground">Second Brain</span>
<span className="block text-[11px] leading-4 text-muted-foreground">Local-first workspace</span>
<span className="block text-[11px] leading-4 text-muted-foreground">
{STATIC_DEMO_MODE ? "Static portfolio demo" : "Local-first workspace"}
</span>
</Link>
<Link
href="/chat"
Expand Down
123 changes: 123 additions & 0 deletions frontend/components/DemoAccessGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"use client";

import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { LockKey, ShieldCheck } from "@phosphor-icons/react";

import {
DEMO_ACCESS_ENABLED,
DEMO_ACCESS_HASH,
DEMO_ACCESS_STORAGE_KEY,
STATIC_DEMO_MODE,
} from "@/lib/demo/config";

type AccessState = "checking" | "granted" | "locked";

async function sha256Hex(value: string): Promise<string> {
const bytes = new TextEncoder().encode(value);
const digest = await window.crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}

export function DemoAccessGate({ children }: { children: ReactNode }) {
const [state, setState] = useState<AccessState>(
STATIC_DEMO_MODE && DEMO_ACCESS_ENABLED ? "checking" : "granted",
);
const [passcode, setPasscode] = useState("");
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!STATIC_DEMO_MODE || !DEMO_ACCESS_ENABLED) return;

const saved = window.localStorage.getItem(DEMO_ACCESS_STORAGE_KEY);
const frame = window.requestAnimationFrame(() => {
setState(saved === DEMO_ACCESS_HASH ? "granted" : "locked");
});
return () => window.cancelAnimationFrame(frame);
}, []);

const submit = async () => {
setError(null);
const normalized = passcode.trim();
if (!normalized) {
setError("Enter the demo passcode.");
return;
}

if (!window.crypto?.subtle) {
setError("This browser cannot verify the passcode locally.");
return;
}

const hash = await sha256Hex(normalized);
if (hash === DEMO_ACCESS_HASH) {
window.localStorage.setItem(DEMO_ACCESS_STORAGE_KEY, hash);
setState("granted");
return;
}
setError("Passcode did not match.");
};

if (state === "granted") return <>{children}</>;

return (
<div className="flex h-full w-full items-center justify-center bg-background px-4 py-6 text-foreground">
<section className="w-full max-w-md rounded-lg border border-border bg-card p-5 shadow-2xl shadow-black/20">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary ring-1 ring-primary/25">
<LockKey size={20} weight="bold" />
</div>
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
Portfolio Demo
</p>
<h1 className="mt-1 text-lg font-semibold leading-6 text-foreground">
Second Brain static preview
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
This read-only Netlify build uses a public-safe corpus and a local passcode check for casual access control.
</p>
</div>
</div>

<form
className="mt-5 grid gap-3"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<label className="grid gap-1.5 text-xs font-medium text-muted-foreground">
<span>Demo passcode</span>
<input
type="password"
value={passcode}
onChange={(event) => setPasscode(event.target.value)}
autoComplete="off"
className="h-10 rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground hover:bg-surface-hover/60 focus:border-primary focus:ring-3 focus:ring-primary/25"
placeholder="Enter passcode"
/>
</label>
{error && (
<p className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-5 text-destructive">
{error}
</p>
)}
<button
type="submit"
className="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-primary/25"
>
<ShieldCheck size={15} weight="bold" />
Open demo
</button>
</form>

<p className="mt-4 text-[11px] leading-5 text-muted-foreground">
The passcode gate is not a security boundary. No private notes, API keys, database URLs, or admin secrets are included in this static build.
</p>
</section>
</div>
);
}
Loading
Loading