diff --git a/README.md b/README.md index 77476fa..ab1b6f4 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 24933c2..27af874 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -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 diff --git a/docs/implementation-notes.md b/docs/implementation-notes.md index d694018..f421949 100644 --- a/docs/implementation-notes.md +++ b/docs/implementation-notes.md @@ -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 diff --git a/docs/portfolio-demo-netlify.md b/docs/portfolio-demo-netlify.md new file mode 100644 index 0000000..209d363 --- /dev/null +++ b/docs/portfolio-demo-netlify.md @@ -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= +``` + +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: (Repo: )`. Verify the live demo link is reachable after deployment. diff --git a/frontend/.env.example b/frontend/.env.example index 10e540b..6ad0a4f 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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= diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index d20f050..d4028fa 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -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 ( +
+
+

+ Second Brain +

+

+ Open the workspace +

+

+ Continue to the chat workspace for cited answers over the public-safe demo corpus. +

+ + Open chat + +
+
+ ); } diff --git a/frontend/components/ConversationSidebar.tsx b/frontend/components/ConversationSidebar.tsx index 70bad80..6364c38 100644 --- a/frontend/components/ConversationSidebar.tsx +++ b/frontend/components/ConversationSidebar.tsx @@ -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"; @@ -136,7 +137,7 @@ function SidebarContent({ onNavigate, onClose }: { onNavigate?: () => void; onCl Second Brain - Local knowledge workspace + {STATIC_DEMO_MODE ? "Static portfolio demo" : "Local knowledge workspace"} @@ -206,6 +207,24 @@ function SidebarContent({ onNavigate, onClose }: { onNavigate?: () => void; onCl ))} + {STATIC_DEMO_MODE ? ( +
+
+ +
+
+

Static demo

+ + read-only + +
+

+ Public-safe fixtures power chat, search, sources, and status. Writes stay local-only. +

+
+
+
+ ) : (
@@ -257,6 +276,7 @@ function SidebarContent({ onNavigate, onClose }: { onNavigate?: () => void; onCl
+ )}
); } @@ -386,7 +406,9 @@ function MobileTopBar({ onOpen }: { onOpen: () => void }) { Second Brain - Local-first workspace + + {STATIC_DEMO_MODE ? "Static portfolio demo" : "Local-first workspace"} + { + 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( + STATIC_DEMO_MODE && DEMO_ACCESS_ENABLED ? "checking" : "granted", + ); + const [passcode, setPasscode] = useState(""); + const [error, setError] = useState(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 ( +
+
+
+
+ +
+
+

+ Portfolio Demo +

+

+ Second Brain static preview +

+

+ This read-only Netlify build uses a public-safe corpus and a local passcode check for casual access control. +

+
+
+ +
{ + event.preventDefault(); + void submit(); + }} + > + + {error && ( +

+ {error} +

+ )} + +
+ +

+ The passcode gate is not a security boundary. No private notes, API keys, database URLs, or admin secrets are included in this static build. +

+
+
+ ); +} diff --git a/frontend/components/Providers.tsx b/frontend/components/Providers.tsx index b22440c..455a5d8 100644 --- a/frontend/components/Providers.tsx +++ b/frontend/components/Providers.tsx @@ -1,10 +1,13 @@ "use client"; import { QueryClientProvider } from "@tanstack/react-query"; +import { DemoAccessGate } from "@/components/DemoAccessGate"; import { queryClient } from "@/lib/query-client"; export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/frontend/lib/api/client-types.ts b/frontend/lib/api/client-types.ts new file mode 100644 index 0000000..81c42a1 --- /dev/null +++ b/frontend/lib/api/client-types.ts @@ -0,0 +1,122 @@ +import type { + AppStatusResponse, + Briefing, + BriefingListResponse, + CaptureRequest, + CaptureResponse, + ChatRequest, + ChatResponse, + ChatStreamComplete, + ChatStreamDelta, + ConversationDetailResponse, + ConversationListResponse, + DataExportResponse, + DeleteDocumentResponse, + DeleteSourceResponse, + DocumentContentResponse, + DocumentListResponse, + DocumentSummary, + EvalCandidateExportResponse, + FeedbackAnalyticsResponse, + FeedbackRequest, + FeedbackResponse, + HealthResponse, + IngestRequest, + IngestResponse, + NegativeFeedbackListResponse, + PromoteEvalCandidateRequest, + PromoteEvalCandidateResponse, + PurgeRetentionResponse, + ResearchJob, + ResearchJobListResponse, + ResearchJobRequest, + SearchResponse, + SourceListResponse, + SourceRecord, + TaskItem, + TaskListResponse, + TaskStatus, +} from "./types"; + +export interface ChatStreamHandlers { + onDelta: (delta: ChatStreamDelta) => void; + onComplete: (complete: ChatStreamComplete) => void; + signal?: AbortSignal; +} + +export interface ApiClient { + getHealth(): Promise; + getStatus(): Promise; + capture(req: CaptureRequest): Promise; + ingest(req: IngestRequest): Promise; + ingestUpload(formData: FormData): Promise; + chat(req: ChatRequest): Promise; + chatStream(req: ChatRequest, handlers: ChatStreamHandlers): Promise; + search(params: { + q: string; + top_k?: number; + source_ids?: number[]; + tags?: string[]; + }): Promise; + listConversations(): Promise; + getConversation(id: number): Promise; + submitFeedback(req: FeedbackRequest): Promise; + getFeedbackAnalytics(days?: number): Promise; + listNegativeFeedback(params?: { + limit?: number; + offset?: number; + days?: number; + }): Promise; + getFeedbackEvalCandidates(params?: { + limit?: number; + offset?: number; + days?: number; + }): Promise; + promoteFeedbackEvalCandidate( + feedbackId: number, + req: PromoteEvalCandidateRequest, + adminToken: string, + ): Promise; + getLatestBriefing(): Promise; + listBriefings(limit?: number): Promise; + listTasks(params?: { + status?: TaskStatus; + limit?: number; + }): Promise; + createTask(req: { title: string; detail?: string | null }): Promise; + updateTask(id: number, req: { status: TaskStatus }): Promise; + enqueueResearchJob(req: ResearchJobRequest): Promise; + listResearchJobs(limit?: number): Promise; + getResearchJob(id: number): Promise; + listSources(limit?: number): Promise; + updateSource( + sourceId: number, + req: { name: string }, + adminToken: string, + ): Promise; + listSourceDocuments( + sourceId: number, + limit?: number, + ): Promise; + getDocumentContent(documentId: number): Promise; + updateDocument( + documentId: number, + req: { title: string }, + adminToken: string, + ): Promise; + updateDocumentContent( + documentId: number, + req: { content: string }, + adminToken: string, + ): Promise; + deleteDocument( + documentId: number, + adminToken: string, + ): Promise; + exportSource(sourceId: number, adminToken: string): Promise; + deleteSource(sourceId: number, adminToken: string): Promise; + purgeRetention(params: { + older_than_days?: number; + adminToken: string; + }): Promise; +} diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 4eeddc0..21ad54e 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -37,6 +37,9 @@ import type { TaskListResponse, TaskStatus, } from "./types"; +import type { ApiClient, ChatStreamHandlers } from "./client-types"; +import { demoApi } from "./demo-client"; +import { STATIC_DEMO_MODE } from "@/lib/demo/config"; const BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000"; const API_TOKEN_STORAGE_KEY = "second-brain.api-token"; @@ -70,12 +73,6 @@ export function isChatStreamUnavailableError( return error instanceof ChatStreamUnavailableError; } -export interface ChatStreamHandlers { - onDelta: (delta: ChatStreamDelta) => void; - onComplete: (complete: ChatStreamComplete) => void; - signal?: AbortSignal; -} - async function apiFetch(path: string, init?: RequestInit): Promise { const headers = buildHeaders(init?.headers, init?.body); const res = await fetch(`${BASE}${path}`, { @@ -211,7 +208,7 @@ async function streamChat( } } -export const api = { +export const liveApi = { getHealth(): Promise { return apiFetch("/health"); }, @@ -440,4 +437,8 @@ export const api = { headers: { "X-Second-Brain-Admin-Token": params.adminToken }, }); }, -}; +} satisfies ApiClient; + +export type { ApiClient, ChatStreamHandlers } from "./client-types"; + +export const api: ApiClient = STATIC_DEMO_MODE ? demoApi : liveApi; diff --git a/frontend/lib/api/demo-client.ts b/frontend/lib/api/demo-client.ts new file mode 100644 index 0000000..a638e81 --- /dev/null +++ b/frontend/lib/api/demo-client.ts @@ -0,0 +1,621 @@ +import type { ApiClient, ChatStreamHandlers } from "@/lib/api/client-types"; +import type { + CaptureResponse, + ChatRequest, + ChatResponse, + ConversationDetailResponse, + ConversationListResponse, + DataExportResponse, + DocumentContentResponse, + DocumentListResponse, + FeedbackResponse, + HealthResponse, + IngestResponse, + MessageOut, + ResearchJob, + SearchResponse, + SourceListResponse, + TaskStatus, +} from "@/lib/api/types"; +import { + DEMO_BRIEFINGS, + DEMO_DATE, + DEMO_DOCUMENTS, + DEMO_EVAL_CANDIDATES, + DEMO_FEEDBACK_ANALYTICS, + DEMO_NEGATIVE_FEEDBACK, + DEMO_RESEARCH_JOBS, + DEMO_SOURCE_ID, + DEMO_SOURCE_NAME, + DEMO_SOURCE_RECORD, + DEMO_SOURCE_SUMMARY, + DEMO_SOURCE_URI, + DEMO_SUGGESTED_PROMPTS, + DEMO_TASKS, +} from "@/lib/demo/public-demo-data"; +import { + fallbackRankedDocuments, + searchDemoDocuments, + toCitation, + toSearchHit, + type RankedDemoDocument, +} from "@/lib/demo/static-search"; + +const READ_ONLY_MESSAGE = + "This static portfolio demo is read-only. Chat, search, sources, status, feedback previews, tasks, research, and briefings use public-safe fixture data. Writes require the local app."; +const CONVERSATION_STORAGE_KEY = "second-brain.static-demo-conversations"; +const MODEL = "static-demo-rag"; + +let conversationsCache: ConversationDetailResponse[] | null = null; +let nextConversationId = 9100; +let nextMessageId = 9300; +let nextFeedbackId = 3100; + +function readOnlyReject(): Promise { + return Promise.reject(new Error(READ_ONLY_MESSAGE)); +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function nowIso(): string { + return new Date().toISOString(); +} + +function conversationTitle(message: string): string { + const trimmed = message.trim().replace(/\s+/g, " "); + return trimmed.length > 72 ? `${trimmed.slice(0, 69)}...` : trimmed || "Static demo chat"; +} + +function rankedByDocumentIds(ids: number[]): RankedDemoDocument[] { + return ids + .map((id, index) => { + const document = DEMO_DOCUMENTS.find((item) => item.summary.id === id); + return document ? { document, score: 1 - index * 0.05 } : null; + }) + .filter((item): item is RankedDemoDocument => item !== null); +} + +function responseFromRankedDocuments( + req: ChatRequest, + ranked: RankedDemoDocument[], + conversationId: number, + messageId: number, +): ChatResponse { + const question = req.message.toLowerCase(); + const agentic = Boolean(req.options?.agentic); + let selected = ranked.length ? ranked.slice(0, 3) : fallbackRankedDocuments(); + + let answer: string; + if (question.includes("regular") && question.includes("agentic")) { + selected = rankedByDocumentIds([101, 102, 107]); + answer = + "Regular RAG is the fast default: it runs one bounded hybrid retrieval pass, fuses full-text and pgvector candidates, and answers with validated citation markers [1]. Agentic RAG is opt-in and read-only: it plans focused subqueries, searches from several angles, merges evidence, and can retry weak evidence before returning through the same citation validator [2]. Use regular RAG for direct questions over a compact set of notes, and Agentic RAG for comparisons, decomposition, or questions that need evidence gathered from several angles [1][2]."; + } else if (question.includes("local") || question.includes("cost") || question.includes("runtime")) { + selected = rankedByDocumentIds([103, 104]); + answer = + "Second Brain defaults to an on-demand local Docker Compose runtime, so Postgres with pgvector, the API, worker, and frontend run only when the owner needs them [1]. That posture avoids idle cloud uptime costs and keeps private knowledge off public demo infrastructure [1]. Optional cloud recipes remain for short demos, but they are not the default production runtime [1]."; + } else if (question.includes("mcp") || question.includes("tools") || question.includes("guard")) { + selected = rankedByDocumentIds([105]); + answer = + "The MCP server exposes trusted local tools over stdio: search notes, list tasks, and send digest are available by default [1]. Durable mutations such as create task and research topic require explicit local opt-in before they can write data [1]. That keeps the portfolio story easy to inspect while preserving intentional action boundaries [1]."; + } else if (question.includes("feedback") || question.includes("eval") || question.includes("quality")) { + selected = rankedByDocumentIds([106]); + answer = + "Feedback becomes reviewable eval coverage instead of automatic promotion [1]. Thumbs-down examples can be labeled and exported as YAML fragments for the source-controlled eval dataset, while MLflow and CI record quality metrics so retrieval, refusals, and prompt changes can be compared over time [1]."; + } else if (question.includes("weak") || question.includes("citation") || question.includes("missing")) { + selected = rankedByDocumentIds([107, 102]); + answer = + "Second Brain expects answers to be grounded in retrieved evidence and validates visible citation markers [1]. If the model produces unsupported or uncited content, the backend can replace it with a safer failure message [1]. Both regular and Agentic RAG return through that citation validator, which keeps weak-context behavior consistent [1][2]."; + } else if (selected.length > 0) { + answer = + `From the public demo corpus, the closest evidence is "${selected[0].document.summary.title}" [1]. It shows that this portfolio build is a read-only static preview of the same local-first Second Brain workflow, with cited chat and search backed by public-safe fixture documents [1].`; + } else { + answer = + "I can only answer from the public-safe static demo corpus. Try one of the suggested prompts about regular RAG, Agentic RAG, local-first runtime, MCP tools, feedback/evals, or citation safety."; + } + const citations = selected.map((item, index) => toCitation(item, index + 1, index)); + + return { + conversation_id: conversationId, + message_id: messageId, + answer, + citations, + usage: { + prompt_tokens: req.message.length, + completion_tokens: answer.length, + total_tokens: req.message.length + answer.length, + }, + model: MODEL, + latency_ms: agentic ? 68 : 44, + retrieval: { + method: agentic ? "agentic_hybrid_static" : "hybrid_static", + candidates_vector: 7, + candidates_vector_raw: 7, + candidates_fulltext: 7, + fused_returned: citations.length, + ...(agentic + ? { + agentic: { + enabled: true, + strategy: "static_demo_planner", + subqueries: [ + req.message, + "retrieve operating model", + "retrieve safety and governance boundaries", + ], + subquery_hit_counts: [selected.length, 2, 2], + deduped_chunks: selected.length, + selected_chunks: selected.length, + weak_evidence: citations.length === 0, + planner_failed: false, + verifier_used: true, + fallback_used: false, + step_budget: { + max_subqueries: 4, + recursion_limit: 8, + }, + }, + } + : {}), + }, + }; +} + +function messageOut(params: { + id: number; + role: "user" | "assistant"; + content: string; + createdAt: string; + response?: ChatResponse; +}): MessageOut { + return { + id: params.id, + role: params.role, + content: params.content, + model: params.response?.model ?? null, + latency_ms: params.response?.latency_ms ?? null, + created_at: params.createdAt, + retrievals: + params.response?.citations.map((citation, index) => ({ + chunk_id: citation.chunk_id, + rank: index + 1, + score: citation.score, + vector_score: citation.vector_score, + fulltext_score: citation.fulltext_score, + method: citation.method, + })) ?? [], + citations: params.response?.citations ?? [], + }; +} + +function createSeedConversation( + id: number, + userMessageId: number, + assistantMessageId: number, + prompt: string, + agentic: boolean, +): ConversationDetailResponse { + const response = responseFromRankedDocuments( + { + message: prompt, + conversation_id: id, + options: { agentic, include_chunks: true }, + }, + searchDemoDocuments({ q: prompt, topK: 4 }), + id, + assistantMessageId, + ); + + return { + id, + title: conversationTitle(prompt), + created_at: "2026-06-07T13:00:00.000Z", + updated_at: "2026-06-07T13:01:00.000Z", + messages: [ + messageOut({ + id: userMessageId, + role: "user", + content: prompt, + createdAt: "2026-06-07T13:00:00.000Z", + }), + messageOut({ + id: assistantMessageId, + role: "assistant", + content: response.answer, + createdAt: "2026-06-07T13:01:00.000Z", + response, + }), + ], + }; +} + +function seedConversations(): ConversationDetailResponse[] { + return [ + createSeedConversation(9001, 9201, 9202, DEMO_SUGGESTED_PROMPTS[0], true), + createSeedConversation(9002, 9203, 9204, DEMO_SUGGESTED_PROMPTS[4], false), + ]; +} + +function readStoredConversations(): ConversationDetailResponse[] { + if (typeof window === "undefined") return seedConversations(); + const raw = window.localStorage.getItem(CONVERSATION_STORAGE_KEY); + if (!raw) return seedConversations(); + try { + const parsed = JSON.parse(raw) as ConversationDetailResponse[]; + return Array.isArray(parsed) && parsed.length ? parsed : seedConversations(); + } catch { + return seedConversations(); + } +} + +function writeStoredConversations(conversations: ConversationDetailResponse[]): void { + if (typeof window === "undefined") return; + window.localStorage.setItem(CONVERSATION_STORAGE_KEY, JSON.stringify(conversations)); +} + +function conversations(): ConversationDetailResponse[] { + if (!conversationsCache) { + conversationsCache = readStoredConversations(); + nextConversationId = + Math.max(9100, ...conversationsCache.map((conversation) => conversation.id)) + 1; + nextMessageId = + Math.max( + 9300, + ...conversationsCache.flatMap((conversation) => + conversation.messages.map((message) => message.id), + ), + ) + 1; + } + return conversationsCache; +} + +function saveConversation(conversation: ConversationDetailResponse): void { + const rows = conversations(); + const existingIndex = rows.findIndex((item) => item.id === conversation.id); + if (existingIndex === -1) rows.unshift(conversation); + else rows[existingIndex] = conversation; + conversationsCache = rows; + writeStoredConversations(rows); +} + +function recordChat(req: ChatRequest): ChatResponse { + const ranked = searchDemoDocuments({ + q: req.message, + topK: req.top_k ?? 4, + sourceIds: req.filters?.source_ids ?? undefined, + tags: req.filters?.tags ?? undefined, + }); + const createdAt = nowIso(); + let conversation = req.conversation_id + ? conversations().find((item) => item.id === req.conversation_id) + : undefined; + + if (!conversation) { + conversation = { + id: nextConversationId++, + title: conversationTitle(req.message), + created_at: createdAt, + updated_at: createdAt, + messages: [], + }; + } + + const userMessageId = nextMessageId++; + const assistantMessageId = nextMessageId++; + const response = responseFromRankedDocuments(req, ranked, conversation.id, assistantMessageId); + conversation.messages.push( + messageOut({ + id: userMessageId, + role: "user", + content: req.message, + createdAt, + }), + messageOut({ + id: assistantMessageId, + role: "assistant", + content: response.answer, + createdAt: nowIso(), + response, + }), + ); + conversation.updated_at = nowIso(); + saveConversation(conversation); + return response; +} + +function delay(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")); + return; + } + const timer = globalThis.setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + globalThis.clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }, + { once: true }, + ); + }); +} + +function chunkAnswer(answer: string): string[] { + const chunks: string[] = []; + for (let index = 0; index < answer.length; index += 56) { + chunks.push(answer.slice(index, index + 56)); + } + return chunks; +} + +function documentById(id: number) { + return DEMO_DOCUMENTS.find((document) => document.summary.id === id); +} + +export const demoApi = { + async getHealth(): Promise { + return { status: "ok", db: "static-fixture", embedder: "static-demo" }; + }, + + async getStatus() { + return { + status: "ok", + database: { + reachable: true, + migration_current: "static-export", + migration_head: "static-export", + migrated: true, + error: null, + }, + worker: { + status: "idle", + queued: 0, + running: 0, + done: 1, + failed: 0, + latest_finished_at: DEMO_RESEARCH_JOBS[0]?.finished_at ?? null, + latest_error: null, + }, + knowledge: { + source_count: 1, + document_count: DEMO_DOCUMENTS.length, + embedded_document_count: DEMO_DOCUMENTS.length, + chunk_count: DEMO_DOCUMENTS.length, + embedding_count: DEMO_DOCUMENTS.length, + latest_document_at: DEMO_DATE, + }, + runtime: { + llm_provider: "static-demo", + llm_model: MODEL, + embedding_provider: "static-fixture", + embedding_model: "public-demo-corpus", + agentic_rag_enabled: true, + mcp_mutations_enabled: false, + }, + }; + }, + + capture(): Promise { + return readOnlyReject(); + }, + + ingest(): Promise { + return readOnlyReject(); + }, + + ingestUpload(): Promise { + return readOnlyReject(); + }, + + async chat(req: ChatRequest): Promise { + return recordChat(req); + }, + + async chatStream(req: ChatRequest, handlers: ChatStreamHandlers): Promise { + const response = recordChat(req); + for (const chunk of chunkAnswer(response.answer)) { + await delay(14, handlers.signal); + if (!chunk) continue; + handlers.onDelta({ text: chunk }); + } + handlers.onComplete(response); + }, + + async search(params: { + q: string; + top_k?: number; + source_ids?: number[]; + tags?: string[]; + }): Promise { + const ranked = searchDemoDocuments({ + q: params.q, + topK: params.top_k ?? 10, + sourceIds: params.source_ids, + tags: params.tags, + }); + return { + query: params.q, + hits: ranked.map(toSearchHit), + retrieval: { + method: "hybrid_static", + corpus: DEMO_SOURCE_NAME, + source_count: 1, + document_count: DEMO_DOCUMENTS.length, + }, + }; + }, + + async listConversations(): Promise { + const rows = conversations().map((conversation) => ({ + id: conversation.id, + title: conversation.title, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + message_count: conversation.messages.length, + })); + return { conversations: rows, total: rows.length }; + }, + + async getConversation(id: number): Promise { + const conversation = conversations().find((item) => item.id === id); + if (!conversation) throw new Error(`404 Static demo conversation ${id} was not found`); + return clone(conversation); + }, + + async submitFeedback(req): Promise { + return { + id: nextFeedbackId++, + message_id: req.message_id, + rating: req.rating, + comment: req.comment ?? null, + created_at: nowIso(), + }; + }, + + async getFeedbackAnalytics(days = 30) { + return { ...clone(DEMO_FEEDBACK_ANALYTICS), window_days: days }; + }, + + async listNegativeFeedback(params = {}) { + const limit = params.limit ?? DEMO_NEGATIVE_FEEDBACK.length; + const offset = params.offset ?? 0; + const items = DEMO_NEGATIVE_FEEDBACK.slice(offset, offset + limit); + return { + items: clone(items), + total: DEMO_NEGATIVE_FEEDBACK.length, + limit, + offset, + }; + }, + + async getFeedbackEvalCandidates() { + return { + generated_at: DEMO_DATE, + source: "static-demo", + total: DEMO_EVAL_CANDIDATES.length, + cases: clone(DEMO_EVAL_CANDIDATES), + }; + }, + + promoteFeedbackEvalCandidate() { + return readOnlyReject(); + }, + + async getLatestBriefing() { + return clone(DEMO_BRIEFINGS[0]); + }, + + async listBriefings(limit = 20) { + const briefings = DEMO_BRIEFINGS.slice(0, limit); + return { briefings: clone(briefings), total: DEMO_BRIEFINGS.length }; + }, + + async listTasks(params: { status?: TaskStatus; limit?: number } = {}) { + const filtered = params.status + ? DEMO_TASKS.filter((task) => task.status === params.status) + : DEMO_TASKS; + const tasks = filtered.slice(0, params.limit ?? filtered.length); + return { tasks: clone(tasks), total: filtered.length }; + }, + + createTask() { + return readOnlyReject(); + }, + + updateTask() { + return readOnlyReject(); + }, + + enqueueResearchJob() { + return readOnlyReject(); + }, + + async listResearchJobs(limit = 20) { + const jobs = DEMO_RESEARCH_JOBS.slice(0, limit); + return { jobs: clone(jobs), total: DEMO_RESEARCH_JOBS.length }; + }, + + async getResearchJob(id: number): Promise { + const job = DEMO_RESEARCH_JOBS.find((item) => item.id === id); + if (!job) throw new Error(`404 Static demo research job ${id} was not found`); + return clone(job); + }, + + async listSources(limit = 100): Promise { + const sources = [DEMO_SOURCE_SUMMARY].slice(0, limit); + return { sources: clone(sources), total: 1 }; + }, + + async updateSource() { + return readOnlyReject(); + }, + + async listSourceDocuments(sourceId: number, limit = 100): Promise { + if (sourceId !== DEMO_SOURCE_ID) { + return { source: clone(DEMO_SOURCE_RECORD), documents: [], total: 0 }; + } + const documents = DEMO_DOCUMENTS.map((document) => document.summary).slice(0, limit); + return { + source: clone(DEMO_SOURCE_RECORD), + documents: clone(documents), + total: DEMO_DOCUMENTS.length, + }; + }, + + async getDocumentContent(documentId: number): Promise { + const document = documentById(documentId); + if (!document) throw new Error(`404 Static demo document ${documentId} was not found`); + return { + source: clone(DEMO_SOURCE_RECORD), + document: clone(document.summary), + content: document.content, + content_source: "raw_text", + truncated: false, + }; + }, + + async updateDocument() { + return readOnlyReject(); + }, + + async updateDocumentContent() { + return readOnlyReject(); + }, + + async deleteDocument() { + return readOnlyReject(); + }, + + async exportSource(sourceId: number): Promise { + if (sourceId !== DEMO_SOURCE_ID) throw new Error(`404 Static demo source ${sourceId} was not found`); + return { + source: { + id: DEMO_SOURCE_ID, + type: DEMO_SOURCE_RECORD.type, + name: DEMO_SOURCE_NAME, + uri: DEMO_SOURCE_URI, + config: { + demo: "static", + allows_public_uploads: false, + }, + created_at: DEMO_SOURCE_RECORD.created_at, + }, + documents: DEMO_DOCUMENTS.map((document) => ({ + ...document.summary, + content: document.content, + })), + document_count: DEMO_DOCUMENTS.length, + }; + }, + + async deleteSource() { + return readOnlyReject(); + }, + + async purgeRetention() { + return readOnlyReject(); + }, +} satisfies ApiClient; diff --git a/frontend/lib/demo/config.ts b/frontend/lib/demo/config.ts new file mode 100644 index 0000000..006f71b --- /dev/null +++ b/frontend/lib/demo/config.ts @@ -0,0 +1,9 @@ +export const STATIC_DEMO_MODE = + process.env.NEXT_PUBLIC_SECOND_BRAIN_DEMO_MODE === "static"; + +export const DEMO_ACCESS_HASH = + process.env.NEXT_PUBLIC_DEMO_ACCESS_HASH?.trim().toLowerCase() ?? ""; + +export const DEMO_ACCESS_STORAGE_KEY = "second-brain.static-demo-access"; + +export const DEMO_ACCESS_ENABLED = STATIC_DEMO_MODE && DEMO_ACCESS_HASH.length > 0; diff --git a/frontend/lib/demo/public-demo-data.ts b/frontend/lib/demo/public-demo-data.ts new file mode 100644 index 0000000..2aba299 --- /dev/null +++ b/frontend/lib/demo/public-demo-data.ts @@ -0,0 +1,278 @@ +import type { + Briefing, + DocumentSummary, + EvalCandidate, + FeedbackAnalyticsResponse, + NegativeFeedbackItem, + ResearchJob, + SourceRecord, + SourceSummary, + TaskItem, +} from "@/lib/api/types"; + +export const DEMO_SOURCE_ID = 1; +export const DEMO_SOURCE_NAME = "Second Brain Public Demo Corpus"; +export const DEMO_SOURCE_URI = "https://github.com/tomnguyen103/second-brain"; +export const DEMO_DATE = "2026-06-07T15:00:00.000Z"; + +export const DEMO_SUGGESTED_PROMPTS = [ + "Compare regular RAG and Agentic RAG in Second Brain. When should I use each?", + "What does the local-first runtime protect against?", + "What MCP tools does Second Brain expose, and which actions are guarded?", + "How does the feedback and eval workflow improve answer quality?", + "What happens when evidence is weak or citations are missing?", +] as const; + +export interface DemoCorpusDocument { + summary: DocumentSummary; + content: string; +} + +const COMMON_TAGS = ["public-demo", "second-brain"]; + +function documentSummary( + id: number, + title: string, + externalId: string, + tags: string[], +): DocumentSummary { + return { + id, + source_id: DEMO_SOURCE_ID, + title, + external_id: externalId, + content_type: "text/plain", + content_hash: `demo-${externalId}`, + status: "embedded", + tags: [...COMMON_TAGS, ...tags], + chunk_count: 1, + raw_text_available: true, + ingested_at: DEMO_DATE, + created_at: DEMO_DATE, + updated_at: DEMO_DATE, + }; +} + +export const DEMO_SOURCE_RECORD: SourceRecord = { + id: DEMO_SOURCE_ID, + type: "manual", + name: DEMO_SOURCE_NAME, + uri: DEMO_SOURCE_URI, + created_at: DEMO_DATE, + updated_at: DEMO_DATE, +}; + +export const DEMO_DOCUMENTS: DemoCorpusDocument[] = [ + { + summary: documentSummary(101, "Regular RAG operating model", "public-demo-regular-rag", [ + "rag", + "hybrid-search", + "pgvector", + ]), + content: + "Regular RAG in Second Brain is the fast default path for direct questions. It runs one bounded hybrid retrieval pass over the selected sources, combining PostgreSQL full-text search with pgvector semantic search. Candidates are fused, the strongest chunks are sent to the configured LLM, and the final answer must include validated citation markers. Use regular RAG when the question can be answered from a compact set of retrieved notes without extra planning.", + }, + { + summary: documentSummary(102, "Agentic RAG operating model", "public-demo-agentic-rag", [ + "rag", + "agentic-rag", + "langgraph", + ]), + content: + "Agentic RAG in Second Brain is an opt-in read-only retrieval workflow built with LangGraph. It plans multiple focused subqueries, searches existing notes for each subquery, merges the evidence, and can retry weak evidence before returning an answer through the same citation validator as regular RAG. Use Agentic RAG for comparison, decomposition, or questions that need evidence gathered from several angles. The agentic path does not mutate notes, tasks, or source data.", + }, + { + summary: documentSummary(103, "Local-first runtime posture", "public-demo-local-first", [ + "local-first", + "docker-compose", + "runtime", + ]), + content: + "Second Brain defaults to a local-first Docker Compose runtime. The owner starts PostgreSQL with pgvector, the FastAPI backend, the worker, and the Next.js frontend only when needed. This avoids paying for idle cloud uptime and keeps normal use on the owner's machine. Optional cloud deployment recipes remain for short demos, but they are not the default production posture. Uploaded private knowledge should not be stored in a public demo database.", + }, + { + summary: documentSummary(104, "Source management and governance", "public-demo-source-governance", [ + "sources", + "governance", + "admin", + ]), + content: + "The web workspace includes a Sources management home where source folders and files can be inspected, renamed, edited, exported, or deleted through guarded workflows. Destructive actions require the admin token, and raw-text retention can be purged without removing searchable chunks until source erasure is requested. The public demo corpus is intentionally small and public-safe so visitors can query the app without uploading private documents.", + }, + { + summary: documentSummary(105, "MCP tools and action boundaries", "public-demo-mcp-tools", [ + "mcp", + "tools", + "actions", + ]), + content: + "Second Brain exposes MCP tools over stdio for trusted local clients. Search notes, list tasks, and send digest are available by default. Mutating actions such as create task and research topic require explicit local opt-in before they can write durable data. This boundary keeps the demo and normal runtime inspectable: read-only retrieval is easy to show, while mutations stay guarded and intentional.", + }, + { + summary: documentSummary(106, "Feedback and eval workflow", "public-demo-feedback-eval", [ + "feedback", + "eval", + "mlflow", + ]), + content: + "Second Brain turns feedback into reviewable eval coverage instead of promoting cases automatically. Thumbs-down feedback can be reviewed, labeled, and exported as YAML fragments for the source-controlled eval dataset. The eval harness records metrics with MLflow and CI runs a deterministic quality gate. This makes retrieval quality, refusal behavior, and prompt changes easier to compare over time.", + }, + { + summary: documentSummary(107, "Citation safety and weak-context behavior", "public-demo-citation-safety", [ + "citations", + "safety", + "rag", + ]), + content: + "Chat answers in Second Brain are expected to be grounded in retrieved evidence. The backend validates citation markers and can replace unsupported or uncited model responses with a safer failure message. Retrieval also tracks weak context so the app can refuse when evidence is too thin. Regular RAG and Agentic RAG both return through the citation validator, which keeps the visible answer format consistent.", + }, +]; + +export const DEMO_SOURCE_SUMMARY: SourceSummary = { + ...DEMO_SOURCE_RECORD, + document_count: DEMO_DOCUMENTS.length, + chunk_count: DEMO_DOCUMENTS.reduce((total, doc) => total + doc.summary.chunk_count, 0), + latest_document_at: DEMO_DATE, +}; + +export const DEMO_TASKS: TaskItem[] = [ + { + id: 701, + title: "Review portfolio demo access posture", + detail: "Keep the hosted static data public-safe and use the passcode gate only as casual access control.", + status: "open", + created_at: "2026-06-07T13:05:00.000Z", + }, + { + id: 702, + title: "Seed public demo corpus locally", + detail: "Run python -m app.demo.seed_public before showing the full backend-powered local demo.", + status: "done", + created_at: "2026-06-07T12:10:00.000Z", + }, +]; + +export const DEMO_RESEARCH_JOBS: ResearchJob[] = [ + { + id: 801, + type: "research", + topic: "Static portfolio demo deployment options", + status: "done", + attempts: 1, + last_error: null, + scheduled_at: "2026-06-07T12:22:00.000Z", + started_at: "2026-06-07T12:23:00.000Z", + finished_at: "2026-06-07T12:24:00.000Z", + created_at: "2026-06-07T12:22:00.000Z", + result: { + status: "stored", + document_id: 107, + evidence_count: 3, + }, + }, +]; + +export const DEMO_BRIEFINGS: Briefing[] = [ + { + id: 901, + generated_at: "2026-06-07T14:30:00.000Z", + period_start: "2026-06-06T14:30:00.000Z", + period_end: "2026-06-07T14:30:00.000Z", + summary: "Public demo corpus is ready for cited chat and search.", + body_markdown: + "The current demo corpus highlights regular RAG, Agentic RAG, local-first runtime posture, MCP action boundaries, source governance, feedback review, and citation safety. The hosted Netlify build is static and read-only, while the local app keeps the full FastAPI, Postgres, worker, MCP, and eval workflow available on demand.", + document_count: DEMO_DOCUMENTS.length, + model: "static-demo-fixture", + }, +]; + +export const DEMO_FEEDBACK_ANALYTICS: FeedbackAnalyticsResponse = { + window_days: 30, + total: 9, + positive: 7, + negative: 2, + negative_rate: 2 / 9, + latest_feedback_at: "2026-06-07T14:00:00.000Z", + trend: [ + { date: "2026-05-29", total: 1, positive: 1, negative: 0, negative_rate: 0 }, + { date: "2026-05-30", total: 0, positive: 0, negative: 0, negative_rate: 0 }, + { date: "2026-05-31", total: 2, positive: 1, negative: 1, negative_rate: 0.5 }, + { date: "2026-06-01", total: 1, positive: 1, negative: 0, negative_rate: 0 }, + { date: "2026-06-02", total: 1, positive: 1, negative: 0, negative_rate: 0 }, + { date: "2026-06-03", total: 1, positive: 1, negative: 0, negative_rate: 0 }, + { date: "2026-06-04", total: 0, positive: 0, negative: 0, negative_rate: 0 }, + { date: "2026-06-05", total: 1, positive: 0, negative: 1, negative_rate: 1 }, + { date: "2026-06-06", total: 1, positive: 1, negative: 0, negative_rate: 0 }, + { date: "2026-06-07", total: 1, positive: 1, negative: 0, negative_rate: 0 }, + ], + by_model: [ + { + model: "static-demo-rag", + total: 9, + positive: 7, + negative: 2, + negative_rate: 2 / 9, + avg_latency_ms: 54, + }, + ], + top_negative_documents: [ + { + document_id: 107, + document_title: "Citation safety and weak-context behavior", + source_id: DEMO_SOURCE_ID, + source_name: DEMO_SOURCE_NAME, + negative: 1, + }, + { + document_id: 106, + document_title: "Feedback and eval workflow", + source_id: DEMO_SOURCE_ID, + source_name: DEMO_SOURCE_NAME, + negative: 1, + }, + ], +}; + +export const DEMO_EVAL_CANDIDATES: EvalCandidate[] = [ + { + id: "feedback-3001", + question: "What happens when evidence is weak or citations are missing?", + expected_docs: ["Citation safety and weak-context behavior"], + expected_keywords: ["validated citation markers", "weak context", "safer failure message"], + expect_refusal: true, + metadata: { + feedback_id: 3001, + demo_visibility: "public-safe", + }, + }, +]; + +export const DEMO_NEGATIVE_FEEDBACK: NegativeFeedbackItem[] = [ + { + feedback_id: 3001, + rating: -1, + comment: "Good candidate for checking weak-context refusal wording.", + feedback_created_at: "2026-06-05T14:20:00.000Z", + conversation_id: 9002, + conversation_title: "Citation safety and weak context", + message_id: 9204, + message_created_at: "2026-06-05T14:18:00.000Z", + question_message_id: 9203, + question: "What happens when evidence is weak or citations are missing?", + answer: + "Second Brain expects chat answers to be grounded in retrieved evidence and can replace unsupported responses with a safer failure message when evidence is too thin [1].", + model: "static-demo-rag", + latency_ms: 52, + retrievals: [ + { + chunk_id: 1007, + rank: 1, + score: 0.98, + vector_score: 0.91, + fulltext_score: 0.95, + method: "hybrid", + }, + ], + citations: [], + }, +]; diff --git a/frontend/lib/demo/static-search.ts b/frontend/lib/demo/static-search.ts new file mode 100644 index 0000000..bcc2b03 --- /dev/null +++ b/frontend/lib/demo/static-search.ts @@ -0,0 +1,149 @@ +import type { Citation, SearchHit } from "@/lib/api/types"; +import { DEMO_DOCUMENTS, DEMO_SOURCE_ID, DEMO_SOURCE_NAME, type DemoCorpusDocument } from "./public-demo-data"; + +const STOP_WORDS = new Set([ + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "by", + "can", + "does", + "for", + "from", + "how", + "in", + "is", + "it", + "of", + "on", + "or", + "the", + "this", + "to", + "use", + "what", + "when", + "with", +]); + +export interface RankedDemoDocument { + document: DemoCorpusDocument; + score: number; +} + +function tokens(value: string): string[] { + return value + .toLowerCase() + .replace(/[^a-z0-9-]+/g, " ") + .split(/\s+/) + .filter((token) => token.length > 1 && !STOP_WORDS.has(token)); +} + +function matchesFilters( + document: DemoCorpusDocument, + sourceIds?: number[], + tags?: string[], +): boolean { + if (sourceIds?.length && !sourceIds.includes(DEMO_SOURCE_ID)) return false; + if (!tags?.length) return true; + const docTags = new Set(document.summary.tags.map((tag) => tag.toLowerCase())); + return tags.every((tag) => docTags.has(tag.toLowerCase())); +} + +function scoreDocument(document: DemoCorpusDocument, queryTerms: string[]): number { + if (queryTerms.length === 0) return 0.1; + + const titleTerms = tokens(document.summary.title); + const tagTerms = document.summary.tags.flatMap(tokens); + const contentTerms = tokens(document.content); + + let score = 0; + for (const term of queryTerms) { + if (titleTerms.includes(term)) score += 4; + if (tagTerms.includes(term)) score += 3; + if (contentTerms.includes(term)) score += 1; + } + + if (queryTerms.includes("agentic") && document.summary.title.includes("Agentic")) score += 6; + if (queryTerms.includes("regular") && document.summary.title.includes("Regular")) score += 6; + if (queryTerms.includes("local-first") && document.summary.title.includes("Local-first")) score += 6; + if (queryTerms.includes("mcp") && document.summary.title.includes("MCP")) score += 6; + if (queryTerms.includes("feedback") && document.summary.title.includes("Feedback")) score += 6; + if (queryTerms.includes("citation") && document.summary.title.includes("Citation")) score += 6; + if (queryTerms.includes("weak") && document.summary.title.includes("Citation")) score += 4; + + return score; +} + +export function searchDemoDocuments(params: { + q: string; + topK?: number; + sourceIds?: number[]; + tags?: string[]; +}): RankedDemoDocument[] { + const queryTerms = tokens(params.q); + const topK = params.topK ?? 5; + + return DEMO_DOCUMENTS + .filter((document) => matchesFilters(document, params.sourceIds, params.tags)) + .map((document) => ({ document, score: scoreDocument(document, queryTerms) })) + .filter((ranked) => ranked.score > 0 || queryTerms.length === 0) + .sort((a, b) => { + const scoreDiff = b.score - a.score; + if (scoreDiff !== 0) return scoreDiff; + return a.document.summary.id - b.document.summary.id; + }) + .slice(0, topK); +} + +export function toSearchHit(ranked: RankedDemoDocument, index: number): SearchHit { + const score = Number((0.72 + Math.min(ranked.score, 20) / 100 - index * 0.015).toFixed(4)); + return { + chunk_id: 1000 + ranked.document.summary.id - 100, + document_id: ranked.document.summary.id, + document_title: ranked.document.summary.title, + source_id: DEMO_SOURCE_ID, + source_name: DEMO_SOURCE_NAME, + snippet: ranked.document.content, + score, + vector_score: Number((score - 0.07).toFixed(4)), + fulltext_score: Number((score - 0.03).toFixed(4)), + method: "hybrid", + char_start: 0, + char_end: Math.min(ranked.document.content.length, 420), + }; +} + +export function toCitation( + ranked: RankedDemoDocument, + marker: number, + index = marker - 1, +): Citation { + const hit = toSearchHit(ranked, index); + return { + marker, + chunk_id: hit.chunk_id, + document_id: hit.document_id, + document_title: hit.document_title, + source_id: hit.source_id, + source_name: hit.source_name, + snippet: hit.snippet, + score: hit.score, + vector_score: hit.vector_score, + fulltext_score: hit.fulltext_score, + method: "hybrid", + char_start: hit.char_start, + char_end: hit.char_end, + }; +} + +export function fallbackRankedDocuments(): RankedDemoDocument[] { + return DEMO_DOCUMENTS.slice(0, 3).map((document, index) => ({ + document, + score: 0.3 - index * 0.05, + })); +} diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e9ffa30..ef106df 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,7 +1,22 @@ import type { NextConfig } from "next"; +import path from "node:path"; + +const staticDemoMode = process.env.NEXT_PUBLIC_SECOND_BRAIN_DEMO_MODE === "static"; +const frontendRoot = path.resolve(__dirname); const nextConfig: NextConfig = { - /* config options here */ + turbopack: { + root: frontendRoot, + }, + ...(staticDemoMode + ? { + output: "export" as const, + trailingSlash: true, + images: { + unoptimized: true, + }, + } + : {}), }; export default nextConfig; diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..ffa19cd --- /dev/null +++ b/netlify.toml @@ -0,0 +1,20 @@ +[build] +base = "frontend" +command = "npm ci && npm run build" +publish = "out" + +[build.environment] +NODE_VERSION = "22" +NEXT_PUBLIC_SECOND_BRAIN_DEMO_MODE = "static" +NEXT_PUBLIC_AGENTIC_RAG_ENABLED = "true" +NEXT_TELEMETRY_DISABLED = "1" + +[[headers]] +for = "/*" + +[headers.values] +X-Frame-Options = "DENY" +X-Content-Type-Options = "nosniff" +Referrer-Policy = "strict-origin-when-cross-origin" +Permissions-Policy = "camera=(), microphone=(), geolocation=(), payment=()" +Content-Security-Policy = "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; form-action 'self'"