From 8f914cc86da75242cc3c355ce0bc17426a8e6a9d Mon Sep 17 00:00:00 2001 From: Alan Shurafa Date: Fri, 17 Apr 2026 23:39:12 -0400 Subject: [PATCH 01/23] =?UTF-8?q?[dashboards]=20Open=20Brain=20Dashboard?= =?UTF-8?q?=20Pro=20=E2=80=94=20Next.js=2016=20+=20iron-session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third dashboard flavor: Next.js App Router, Tailwind, iron-session auth, with Browse/Detail/Search/Audit/Ingest views. Fully env-configurable — no hardcoded project URLs. Degrades gracefully for optional REST endpoints (reflections, ingestion-jobs). --- .../open-brain-dashboard-pro/.env.example | 14 + .../open-brain-dashboard-pro/.gitignore | 43 ++ dashboards/open-brain-dashboard-pro/README.md | 143 ++++++ .../app/api/audit/delete/route.ts | 37 ++ .../app/api/audit/route.ts | 40 ++ .../app/api/duplicates/resolve/route.ts | 49 ++ .../app/api/duplicates/route.ts | 36 ++ .../app/api/ingest/[id]/execute/route.ts | 40 ++ .../app/api/ingest/[id]/route.ts | 70 +++ .../app/api/ingest/route.ts | 134 +++++ .../app/api/logout/route.ts | 8 + .../app/api/restricted/route.ts | 86 ++++ .../app/api/search/route.ts | 37 ++ .../app/api/settings/status/route.ts | 84 ++++ .../api/thoughts/[id]/connections/route.ts | 47 ++ .../app/audit/page.tsx | 209 ++++++++ .../app/duplicates/page.tsx | 460 +++++++++++++++++ .../open-brain-dashboard-pro/app/globals.css | 51 ++ .../app/ingest/page.tsx | 124 +++++ .../open-brain-dashboard-pro/app/layout.tsx | 41 ++ .../app/login/LoginForm.tsx | 50 ++ .../app/login/layout.tsx | 8 + .../app/login/page.tsx | 59 +++ .../open-brain-dashboard-pro/app/page.tsx | 70 +++ .../app/search/page.tsx | 161 ++++++ .../app/settings/page.tsx | 207 ++++++++ .../app/thoughts/[id]/page.tsx | 171 +++++++ .../app/thoughts/page.tsx | 155 ++++++ .../components/AddToBrain.tsx | 465 ++++++++++++++++++ .../components/ConnectionsPanel.tsx | 102 ++++ .../components/DeleteModal.tsx | 58 +++ .../components/FormattedDate.tsx | 17 + .../components/QuickCapture.tsx | 39 ++ .../components/RestrictedToggle.tsx | 131 +++++ .../components/SearchBar.tsx | 80 +++ .../components/Sidebar.tsx | 144 ++++++ .../components/StatsWidget.tsx | 59 +++ .../components/ThoughtCard.tsx | 77 +++ .../components/ThoughtDeleteButton.tsx | 36 ++ .../components/ThoughtEditor.tsx | 125 +++++ .../components/ThoughtsFilter.tsx | 110 +++++ .../docs/screenshots/.gitkeep | 0 .../eslint.config.mjs | 18 + .../open-brain-dashboard-pro/lib/api.ts | 225 +++++++++ .../open-brain-dashboard-pro/lib/auth.ts | 66 +++ .../open-brain-dashboard-pro/lib/format.ts | 26 + .../open-brain-dashboard-pro/lib/types.ts | 101 ++++ .../open-brain-dashboard-pro/metadata.json | 26 + .../open-brain-dashboard-pro/next.config.ts | 9 + .../open-brain-dashboard-pro/package.json | 31 ++ .../postcss.config.mjs | 7 + dashboards/open-brain-dashboard-pro/proxy.ts | 27 + .../open-brain-dashboard-pro/public/.gitkeep | 0 .../open-brain-dashboard-pro/tsconfig.json | 34 ++ 54 files changed, 4647 insertions(+) create mode 100644 dashboards/open-brain-dashboard-pro/.env.example create mode 100644 dashboards/open-brain-dashboard-pro/.gitignore create mode 100644 dashboards/open-brain-dashboard-pro/README.md create mode 100644 dashboards/open-brain-dashboard-pro/app/api/audit/delete/route.ts create mode 100644 dashboards/open-brain-dashboard-pro/app/api/audit/route.ts create mode 100644 dashboards/open-brain-dashboard-pro/app/api/duplicates/resolve/route.ts create mode 100644 dashboards/open-brain-dashboard-pro/app/api/duplicates/route.ts create mode 100644 dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/execute/route.ts create mode 100644 dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/route.ts create mode 100644 dashboards/open-brain-dashboard-pro/app/api/ingest/route.ts create mode 100644 dashboards/open-brain-dashboard-pro/app/api/logout/route.ts create mode 100644 dashboards/open-brain-dashboard-pro/app/api/restricted/route.ts create mode 100644 dashboards/open-brain-dashboard-pro/app/api/search/route.ts create mode 100644 dashboards/open-brain-dashboard-pro/app/api/settings/status/route.ts create mode 100644 dashboards/open-brain-dashboard-pro/app/api/thoughts/[id]/connections/route.ts create mode 100644 dashboards/open-brain-dashboard-pro/app/audit/page.tsx create mode 100644 dashboards/open-brain-dashboard-pro/app/duplicates/page.tsx create mode 100644 dashboards/open-brain-dashboard-pro/app/globals.css create mode 100644 dashboards/open-brain-dashboard-pro/app/ingest/page.tsx create mode 100644 dashboards/open-brain-dashboard-pro/app/layout.tsx create mode 100644 dashboards/open-brain-dashboard-pro/app/login/LoginForm.tsx create mode 100644 dashboards/open-brain-dashboard-pro/app/login/layout.tsx create mode 100644 dashboards/open-brain-dashboard-pro/app/login/page.tsx create mode 100644 dashboards/open-brain-dashboard-pro/app/page.tsx create mode 100644 dashboards/open-brain-dashboard-pro/app/search/page.tsx create mode 100644 dashboards/open-brain-dashboard-pro/app/settings/page.tsx create mode 100644 dashboards/open-brain-dashboard-pro/app/thoughts/[id]/page.tsx create mode 100644 dashboards/open-brain-dashboard-pro/app/thoughts/page.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/AddToBrain.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/ConnectionsPanel.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/DeleteModal.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/FormattedDate.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/QuickCapture.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/RestrictedToggle.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/SearchBar.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/Sidebar.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/StatsWidget.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/ThoughtCard.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/ThoughtDeleteButton.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/ThoughtEditor.tsx create mode 100644 dashboards/open-brain-dashboard-pro/components/ThoughtsFilter.tsx create mode 100644 dashboards/open-brain-dashboard-pro/docs/screenshots/.gitkeep create mode 100644 dashboards/open-brain-dashboard-pro/eslint.config.mjs create mode 100644 dashboards/open-brain-dashboard-pro/lib/api.ts create mode 100644 dashboards/open-brain-dashboard-pro/lib/auth.ts create mode 100644 dashboards/open-brain-dashboard-pro/lib/format.ts create mode 100644 dashboards/open-brain-dashboard-pro/lib/types.ts create mode 100644 dashboards/open-brain-dashboard-pro/metadata.json create mode 100644 dashboards/open-brain-dashboard-pro/next.config.ts create mode 100644 dashboards/open-brain-dashboard-pro/package.json create mode 100644 dashboards/open-brain-dashboard-pro/postcss.config.mjs create mode 100644 dashboards/open-brain-dashboard-pro/proxy.ts create mode 100644 dashboards/open-brain-dashboard-pro/public/.gitkeep create mode 100644 dashboards/open-brain-dashboard-pro/tsconfig.json diff --git a/dashboards/open-brain-dashboard-pro/.env.example b/dashboards/open-brain-dashboard-pro/.env.example new file mode 100644 index 000000000..f168d331e --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/.env.example @@ -0,0 +1,14 @@ +# Required: Base URL of your Open Brain REST API gateway +# Typically: https://YOUR-PROJECT-REF.supabase.co/functions/v1/open-brain-rest +NEXT_PUBLIC_API_URL=https://YOUR-PROJECT-REF.supabase.co/functions/v1/open-brain-rest + +# Required: 32+ character secret for iron-session cookie encryption. +# The app will refuse to start if this is missing or too short. +# Generate with: openssl rand -hex 32 +SESSION_SECRET= + +# Optional: SHA-256 hash of a passphrase to unlock restricted/sensitive content. +# Only needed if you've applied the sensitivity-tiers primitive (sensitivity_tier +# column on the thoughts table). Leave unset to hide the lock/unlock toggle. +# Generate with: echo -n "your-passphrase" | shasum -a 256 +# RESTRICTED_PASSPHRASE_HASH= diff --git a/dashboards/open-brain-dashboard-pro/.gitignore b/dashboards/open-brain-dashboard-pro/.gitignore new file mode 100644 index 000000000..c53691451 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (never commit actual secrets) +.env +.env.local +.env.*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/dashboards/open-brain-dashboard-pro/README.md b/dashboards/open-brain-dashboard-pro/README.md new file mode 100644 index 000000000..eab707ad7 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/README.md @@ -0,0 +1,143 @@ +# Open Brain Dashboard Pro + +> A Next.js 16 + Tailwind + iron-session dashboard for browsing, searching, auditing, and ingesting content in your Open Brain. A third flavor alongside the SvelteKit `open-brain-dashboard` and the Next.js `open-brain-dashboard-next`. + +## What It Does + +Seven server-rendered pages backed by iron-session auth and the Open Brain REST API gateway: + +| Page | What you get | +|------|--------------| +| **Dashboard** (`/`) | Stats widget (total thoughts, type distribution, top topics), inline "Add to Brain" capture, and the five most recent thoughts. | +| **Browse** (`/thoughts`) | Paginated thought table with filters for type, source, and minimum importance. | +| **Detail** (`/thoughts/:id`) | Full thought view with metadata panel, inline edit (content/type/importance), delete, and a connections panel when topics/people metadata is present. | +| **Search** (`/search`) | Client-side form for semantic (vector) and full-text search with pagination and similarity scores. | +| **Audit** (`/audit`) | Quality audit of thoughts with `quality_score < 30`, sorted ascending, with two-step bulk delete. | +| **Duplicates** (`/duplicates`) | Semantic near-duplicate pairs with threshold control, side-by-side comparison, and batch resolution (keep A / keep B / keep both). | +| **Ingest** (`/ingest`) | Smart-ingest UI with dry-run preview, extracted-item cards, execute button, and job history. | +| **Settings** (`/settings`) | Connection status, thought type breakdown, source breakdown, and masked API key prefix. | + +## Screenshots + +Screenshots go in `docs/screenshots/` and should be referenced from this README once you add them. + +## Prerequisites + +- A working Open Brain setup ([guide](../../docs/01-getting-started.md)) +- The **REST API gateway** (`open-brain-rest` Edge Function from PR #201) deployed and reachable +- **Node.js 20+** +- A host for the dashboard: Vercel or Netlify free tier works; self-hosting on a Node.js 20+ runtime is also fine + +## Configuration + +All configuration is through environment variables. **The app refuses to start if required variables are missing.** + +| Variable | Required | Description | +|----------|----------|-------------| +| `NEXT_PUBLIC_API_URL` | Yes | Base URL of your Open Brain REST API, typically `https://YOUR-PROJECT-REF.supabase.co/functions/v1/open-brain-rest`. | +| `SESSION_SECRET` | Yes | 32+ character secret used by `iron-session` to encrypt the session cookie. Generate with `openssl rand -hex 32`. | +| `RESTRICTED_PASSPHRASE_HASH` | No | SHA-256 hash of a passphrase that unlocks restricted/sensitive content. Only meaningful if you've applied the [sensitivity-tiers primitive](../../primitives/sensitivity-tiers/). Leave unset to hide the toggle. Generate with `echo -n "your-passphrase" \| shasum -a 256`. | + +Copy `.env.example` to `.env.local` (gitignored) and fill it in. + +## Installation + +```bash +cd dashboards/open-brain-dashboard-pro +npm install + +# Local dev +cp .env.example .env.local # then edit and fill in values +npm run dev # http://localhost:3000 + +# Production build +npm run build +npm start +``` + +For Vercel or Netlify, connect this folder and set the same environment variables in the hosting provider's dashboard. + +## Authentication + +The dashboard uses [`iron-session`](https://github.com/vvo/iron-session) v8 for encrypted HTTP-only session cookies. No API key is ever exposed to the browser. + +1. User enters their Open Brain API key at `/login`. +2. The server hits `GET {NEXT_PUBLIC_API_URL}/health` with `x-brain-key: ` — if the REST gateway responds `200 OK`, the key is accepted. +3. The key is written into an encrypted session cookie named `open_brain_session` (24 h TTL, `httpOnly`, `secure` in production, `sameSite: lax`). +4. Every server component and API route reads the key from the session and injects it into Open Brain REST calls. +5. `/api/logout` destroys the session and redirects back to `/login`. + +If `SESSION_SECRET` is missing or shorter than 32 characters, the app throws at startup so you can't accidentally run with an empty cookie password. + +## Expected REST Endpoints + +The dashboard calls these endpoints on your Open Brain REST gateway (all authenticated via `x-brain-key`): + +| Endpoint | Method | Used by | Required? | +|----------|--------|---------|-----------| +| `/health` | GET | Login validation, Settings status | **Yes** | +| `/count` | GET | Settings status (total + per-type counts) | **Yes** | +| `/stats` | GET | Dashboard stats widget | **Yes** | +| `/thoughts` | GET | Browse, Dashboard recent, Audit (filtered) | **Yes** | +| `/thought/:id` | GET, PUT, DELETE | Detail view, inline edit, delete | **Yes** | +| `/search` | POST | Search page (semantic + full-text) | **Yes** | +| `/capture` | POST | Single-thought "Add to Brain" path | **Yes** | +| `/thought/:id/connections` | GET | Detail page connections panel | Optional — panel hides if it errors | +| `/duplicates`, `/duplicates/resolve` | GET / POST | Duplicates page | Optional — page shows an error otherwise | +| `/ingest`, `/ingestion-jobs`, `/ingestion-jobs/:id`, `/ingestion-jobs/:id/execute` | POST / GET | Ingest page | Optional — page still loads without jobs | + +> **On `/reflections/*`:** The ExoCortex upstream dashboard staged a reflections feature. This fork does not yet ship a reflections UI surface, but the architecture is ready: if you add a reflection panel later and your gateway doesn't serve `/reflections/*`, expect a 404 that the UI should swallow. The existing optional endpoints already degrade this way — the Connections panel, Duplicates page, and Ingest history all swallow fetch errors and render an empty/neutral state instead of crashing. + +## Adapting + +- **Point at a different REST API** — change `NEXT_PUBLIC_API_URL`. Everything else follows. +- **Remove Audit** — delete `app/audit/`, `app/api/audit/`, and the `AuditIcon` nav entry in `components/Sidebar.tsx`. +- **Remove Duplicates** — delete `app/duplicates/`, `app/api/duplicates/`, and the `DuplicatesIcon` nav entry in `components/Sidebar.tsx`. +- **Remove Ingest** — delete `app/ingest/`, `app/api/ingest/`, and the `AddIcon` nav entry. The `AddToBrain` component will no longer be reachable; remove its usage from `app/page.tsx` (the Dashboard). +- **Rebrand** — the wordmark lives in `app/layout.tsx` (`metadata`), `components/Sidebar.tsx` (header), `app/login/page.tsx` (hero), and a few in-page strings (`app/page.tsx`, `app/ingest/page.tsx`). The session cookie name is `open_brain_session` (see `lib/auth.ts` and `proxy.ts`). +- **Change the color palette** — edit `app/globals.css`. The CSS variables under `@theme inline` drive every surface color. +- **Add a new page** — drop a `page.tsx` under `app/` following the existing patterns. For protected pages, call `await requireSessionOrRedirect()` at the top and do REST work from the server. + +## Deployment + +### Vercel + +1. Import the `dashboards/open-brain-dashboard-pro/` folder as a new project (or use `vercel link` from inside it). +2. Set `NEXT_PUBLIC_API_URL` and `SESSION_SECRET` (and optionally `RESTRICTED_PASSPHRASE_HASH`) in Project Settings → Environment Variables. +3. Deploy. Vercel's free tier is sufficient — the dashboard does only lightweight server-side proxy work. + +### Netlify + +1. Point a new site at the folder. Netlify will detect Next.js automatically. +2. Set the same environment variables. +3. Deploy. + +### Self-hosted (Node.js 20+) + +```bash +npm ci +npm run build +NODE_ENV=production \ + NEXT_PUBLIC_API_URL=... \ + SESSION_SECRET=... \ + npm start +``` + +The app listens on port 3000 by default; use `PORT=4000 npm start` to override. + +## Tech Stack + +- **Next.js 16** (App Router, server components) +- **React 19** + TypeScript +- **Tailwind CSS 4** (dark theme, custom palette) +- **iron-session 8** (encrypted cookies) +- **react-markdown + remark-gfm** (available for future rich-content rendering) + +## Troubleshooting + +1. **"SESSION_SECRET env var is required and must be at least 32 characters"** — generate one with `openssl rand -hex 32` and set it. This is intentional; the app refuses to start without it. +2. **Login says "Could not reach API"** — verify `NEXT_PUBLIC_API_URL` is correct and the REST gateway is live. Test with `curl -H "x-brain-key: YOUR_KEY" $NEXT_PUBLIC_API_URL/health`. +3. **Login says "Invalid API key or service unavailable"** — the REST gateway reached but rejected the key. Check `MCP_ACCESS_KEY` (or whatever secret backs `x-brain-key`) in your Edge Function secrets. +4. **Search returns nothing** — semantic search needs embeddings. Verify `OPENROUTER_API_KEY` (or your embedding provider) is set in Supabase secrets and that the `embedding` column is populated. +5. **Ingest page never finishes extracting** — confirm the `smart-ingest` Edge Function is deployed alongside the REST gateway. +6. **Connections panel empty on Detail page** — the panel requires `topics` or `people` in `metadata`. Thoughts enriched through classification have these; raw captures do not. diff --git a/dashboards/open-brain-dashboard-pro/app/api/audit/delete/route.ts b/dashboards/open-brain-dashboard-pro/app/api/audit/delete/route.ts new file mode 100644 index 000000000..c87410ddd --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/audit/delete/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { deleteThought } from "@/lib/api"; +import { requireSession, AuthError } from "@/lib/auth"; + +export async function POST(request: NextRequest) { + // Auth BEFORE body parse — unauthed requests get 401, not 400 + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + try { + const { ids } = (await request.json()) as { ids: number[] }; + if (!Array.isArray(ids) || ids.length === 0) { + return NextResponse.json({ error: "No IDs provided" }, { status: 400 }); + } + + const results = await Promise.allSettled( + ids.map((id) => deleteThought(apiKey, id)) + ); + const failed = results.filter((r) => r.status === "rejected").length; + + return NextResponse.json({ + deleted: ids.length - failed, + failed, + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Delete failed" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/audit/route.ts b/dashboards/open-brain-dashboard-pro/app/api/audit/route.ts new file mode 100644 index 000000000..4d5a552b8 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/audit/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { fetchThoughts } from "@/lib/api"; +import { requireSession, AuthError, getSession } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const session = await getSession(); + const excludeRestricted = !session.restrictedUnlocked; + + const page = parseInt( + request.nextUrl.searchParams.get("page") || "1", + 10 + ); + + try { + // Server-side filter: quality_score_max=29, sorted by quality ascending + const data = await fetchThoughts(apiKey, { + page, + per_page: 50, + quality_score_max: 29, + sort: "quality_score", + order: "asc", + exclude_restricted: excludeRestricted, + }); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/duplicates/resolve/route.ts b/dashboards/open-brain-dashboard-pro/app/api/duplicates/resolve/route.ts new file mode 100644 index 000000000..5113d5c9b --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/duplicates/resolve/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { resolveDuplicate } from "@/lib/api"; +import { requireSession, AuthError } from "@/lib/auth"; + +export async function POST(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + try { + const { action, thought_id_a, thought_id_b } = (await request.json()) as { + action: "keep_a" | "keep_b" | "keep_both"; + thought_id_a: number; + thought_id_b: number; + }; + + if (!thought_id_a || !thought_id_b) { + return NextResponse.json( + { error: "Both thought_id_a and thought_id_b are required" }, + { status: 400 } + ); + } + + if (!["keep_a", "keep_b", "keep_both"].includes(action)) { + return NextResponse.json( + { error: "Invalid action" }, + { status: 400 } + ); + } + + const result = await resolveDuplicate(apiKey, { + thought_id_a, + thought_id_b, + action, + }); + + return NextResponse.json(result); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Resolve failed" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/duplicates/route.ts b/dashboards/open-brain-dashboard-pro/app/api/duplicates/route.ts new file mode 100644 index 000000000..a302b969f --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/duplicates/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { fetchDuplicates } from "@/lib/api"; +import { requireSession, AuthError } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const threshold = parseFloat( + request.nextUrl.searchParams.get("threshold") || "0.85" + ); + const limit = parseInt( + request.nextUrl.searchParams.get("limit") || "50", + 10 + ); + const offset = parseInt( + request.nextUrl.searchParams.get("offset") || "0", + 10 + ); + + try { + const data = await fetchDuplicates(apiKey, { threshold, limit, offset }); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/execute/route.ts b/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/execute/route.ts new file mode 100644 index 000000000..df77eafe6 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/execute/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireSession, AuthError } from "@/lib/auth"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const { id } = await params; + const API_URL = process.env.NEXT_PUBLIC_API_URL; + if (!API_URL) { + return NextResponse.json( + { error: "NEXT_PUBLIC_API_URL not configured" }, + { status: 500 } + ); + } + + try { + const res = await fetch(`${API_URL}/ingestion-jobs/${id}/execute`, { + method: "POST", + headers: { "x-brain-key": apiKey, "Content-Type": "application/json" }, + }); + const data = await res.json(); + if (!res.ok) return NextResponse.json(data, { status: res.status }); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/route.ts b/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/route.ts new file mode 100644 index 000000000..41d9c3f6d --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireSession, AuthError } from "@/lib/auth"; +import type { IngestionItem, IngestionItemMeta } from "@/lib/types"; + +/** Normalize a raw DB ingestion_item row into the web API contract. */ +function normalizeItem(raw: Record): IngestionItem { + const meta = (raw.metadata ?? {}) as Record; + const parsedMeta: IngestionItemMeta = { + type: typeof meta.type === "string" ? meta.type : undefined, + importance: typeof meta.importance === "number" ? meta.importance : undefined, + tags: Array.isArray(meta.tags) ? meta.tags.filter((t): t is string => typeof t === "string") : undefined, + source_snippet: typeof meta.source_snippet === "string" ? meta.source_snippet : undefined, + }; + + return { + id: raw.id as number, + job_id: raw.job_id as number, + content: (raw.extracted_content ?? raw.content ?? "") as string, + action: (raw.action ?? "skip") as string, + reason: (raw.reason as string) ?? null, + status: (raw.status ?? "pending") as string, + matched_thought_id: (raw.matched_thought_id as number) ?? null, + similarity_score: raw.similarity_score != null ? Number(raw.similarity_score) : null, + result_thought_id: (raw.result_thought_id as number) ?? null, + meta: parsedMeta, + }; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const { id } = await params; + const API_URL = process.env.NEXT_PUBLIC_API_URL; + if (!API_URL) { + return NextResponse.json( + { error: "NEXT_PUBLIC_API_URL not configured" }, + { status: 500 } + ); + } + + try { + const res = await fetch(`${API_URL}/ingestion-jobs/${id}`, { + headers: { "x-brain-key": apiKey, "Content-Type": "application/json" }, + }); + const data = await res.json(); + if (!res.ok) return NextResponse.json(data, { status: res.status }); + + // Normalize items from raw DB shape to web contract + const items = Array.isArray(data.items) + ? data.items.map((raw: Record) => normalizeItem(raw)) + : []; + + return NextResponse.json({ job: data.job, items }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/ingest/route.ts b/dashboards/open-brain-dashboard-pro/app/api/ingest/route.ts new file mode 100644 index 000000000..1712ab36f --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/ingest/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + fetchIngestionJobs, + triggerIngest, + captureThought, +} from "@/lib/api"; +import { requireSession, AuthError } from "@/lib/auth"; + +// ── Auto-routing heuristic ────────────────────────────────────────────────── + +/** + * Determines whether input text should be routed to smart-ingest extraction + * (multiple thoughts) rather than single-thought capture. + * + * Heuristic rules (any match → extract): + * 1. Long text (> 500 chars) + * 2. Multiple paragraphs (2+) + * 3. Bullet or numbered lists (2+ items) + * 4. Transcript/speaker markers (2+ lines like "Name: ...") + * 5. Timestamp patterns (e.g. "10:32 AM") + * 6. Email-style headers (From:, Subject:, Date:, To:) + */ +function shouldExtract(text: string): boolean { + if (text.length > 500) return true; + + const paragraphs = text.split(/\n\s*\n/).filter((p) => p.trim()); + if (paragraphs.length >= 2) return true; + + const lines = text.split("\n"); + const bulletLines = lines.filter((l) => + /^\s*[-*\u2022]\s|^\s*\d+[.)]\s/.test(l) + ); + if (bulletLines.length >= 2) return true; + + const speakerLines = lines.filter((l) => /^\s*\w+:\s/.test(l)); + if (speakerLines.length >= 2) return true; + + if (/\d{1,2}:\d{2}\s*(AM|PM)?/i.test(text)) return true; + + if (/^(From|Subject|Date|To):\s/m.test(text)) return true; + + return false; +} + +// ── GET — list ingestion jobs ─────────────────────────────────────────────── + +export async function GET() { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + try { + const jobs = await fetchIngestionJobs(apiKey); + return NextResponse.json(jobs); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed" }, + { status: 500 } + ); + } +} + +// ── POST — unified Add to Brain ───────────────────────────────────────────── + +export async function POST(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + try { + const body = await request.json(); + const { text, mode = "auto", dry_run, skip_classification } = body as { + text: string; + mode?: "auto" | "single" | "extract"; + dry_run?: boolean; + skip_classification?: boolean; + }; + + if (!text?.trim()) { + return NextResponse.json( + { error: "Text is required" }, + { status: 400 } + ); + } + + const trimmed = text.trim(); + const resolvedMode = + mode === "auto" + ? shouldExtract(trimmed) + ? "extract" + : "single" + : mode; + + if (resolvedMode === "single") { + const result = await captureThought(apiKey, trimmed); + return NextResponse.json({ + path: "single" as const, + thought_id: result.thought_id, + type: result.type, + message: "Saved as 1 thought", + }); + } + + // extract path → smart-ingest + const result = await triggerIngest(apiKey, trimmed, { dry_run, skip_classification }); + const extracted = + (result as Record).extracted_count ?? null; + const isDryRun = result.status === "dry_run_complete"; + return NextResponse.json({ + path: "extract" as const, + job_id: result.job_id, + status: result.status, + extracted_count: extracted, + message: isDryRun + ? `Extracted ${extracted ?? "?"} candidate thoughts (dry run)` + : `Extracted thoughts from your input`, + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/logout/route.ts b/dashboards/open-brain-dashboard-pro/app/api/logout/route.ts new file mode 100644 index 000000000..94ddba8ed --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/logout/route.ts @@ -0,0 +1,8 @@ +import { getSession } from "@/lib/auth"; +import { redirect } from "next/navigation"; + +export async function POST() { + const session = await getSession(); + session.destroy(); + redirect("/login"); +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/restricted/route.ts b/dashboards/open-brain-dashboard-pro/app/api/restricted/route.ts new file mode 100644 index 000000000..7ae19b628 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/restricted/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession, requireSession, AuthError } from "@/lib/auth"; + +const RESTRICTED_PASSPHRASE_HASH = process.env.RESTRICTED_PASSPHRASE_HASH ?? ""; + +async function hashPassphrase(passphrase: string): Promise { + const encoded = new TextEncoder().encode(passphrase); + const hash = await crypto.subtle.digest("SHA-256", encoded); + return Array.from(new Uint8Array(hash)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** POST — verify passphrase and unlock restricted content */ +export async function POST(request: NextRequest) { + try { + await requireSession(); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + if (!RESTRICTED_PASSPHRASE_HASH) { + return NextResponse.json( + { error: "Restricted passphrase not configured on server" }, + { status: 503 } + ); + } + + const body = await request.json(); + const passphrase = typeof body.passphrase === "string" ? body.passphrase : ""; + + if (!passphrase) { + return NextResponse.json( + { error: "Passphrase is required" }, + { status: 400 } + ); + } + + const inputHash = await hashPassphrase(passphrase); + + if (inputHash !== RESTRICTED_PASSPHRASE_HASH) { + return NextResponse.json( + { error: "Incorrect passphrase" }, + { status: 401 } + ); + } + + const session = await getSession(); + session.restrictedUnlocked = true; + await session.save(); + + return NextResponse.json({ unlocked: true }); +} + +/** DELETE — re-lock restricted content */ +export async function DELETE() { + try { + await requireSession(); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const session = await getSession(); + session.restrictedUnlocked = false; + await session.save(); + + return NextResponse.json({ unlocked: false }); +} + +/** GET — check current restricted unlock status */ +export async function GET() { + try { + await requireSession(); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const session = await getSession(); + return NextResponse.json({ unlocked: session.restrictedUnlocked === true }); +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/search/route.ts b/dashboards/open-brain-dashboard-pro/app/api/search/route.ts new file mode 100644 index 000000000..a603eed75 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/search/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; +import { searchThoughts } from "@/lib/api"; +import { requireSession, getSession, AuthError } from "@/lib/auth"; + +export async function GET(request: NextRequest) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const session = await getSession(); + const excludeRestricted = session.restrictedUnlocked !== true; + + const q = request.nextUrl.searchParams.get("q"); + const mode = (request.nextUrl.searchParams.get("mode") || "semantic") as + | "semantic" + | "text"; + const page = parseInt(request.nextUrl.searchParams.get("page") || "1", 10); + + if (!q) { + return NextResponse.json({ error: "Query required" }, { status: 400 }); + } + + try { + const data = await searchThoughts(apiKey, q, mode, 100, page, excludeRestricted); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Search failed" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/settings/status/route.ts b/dashboards/open-brain-dashboard-pro/app/api/settings/status/route.ts new file mode 100644 index 000000000..660705324 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/settings/status/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { requireSession, AuthError } from "@/lib/auth"; + +export async function GET() { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const API_URL = process.env.NEXT_PUBLIC_API_URL; + if (!API_URL) { + return NextResponse.json( + { error: "NEXT_PUBLIC_API_URL not configured" }, + { status: 500 } + ); + } + const headers = { "x-brain-key": apiKey, "Content-Type": "application/json" }; + + try { + // Use /count endpoint (simple, reliable) with /health as a secondary liveness check + const [countRes, healthRes] = await Promise.allSettled([ + fetch(`${API_URL}/count`, { headers }), + fetch(`${API_URL}/health`, { headers }), + ]); + + // Parse count + let totalThoughts = 0; + if (countRes.status === "fulfilled" && countRes.value.ok) { + const data = await countRes.value.json(); + totalThoughts = data.count ?? data.total ?? 0; + } + + // Parse health + let healthy = false; + if (healthRes.status === "fulfilled" && healthRes.value.ok) { + const data = await healthRes.value.json(); + healthy = data.status === "ok"; + } + // If health endpoint fails but count worked, we're still connected + if (!healthy && totalThoughts > 0) healthy = true; + + // Build type breakdown by querying /count per type. + // If a user's deployment doesn't have all these types populated, they'll simply report 0. + const types: Record = {}; + const sources: Record = {}; + try { + const typeNames = ["idea", "task", "person_note", "reference", "decision", "lesson", "meeting", "journal"]; + const typeResults = await Promise.allSettled( + typeNames.map(async (type) => { + const res = await fetch(`${API_URL}/count?type=${type}`, { headers }); + if (!res.ok) return { type, count: 0 }; + const data = await res.json(); + return { type, count: data.count ?? 0 }; + }) + ); + for (const r of typeResults) { + if (r.status === "fulfilled" && r.value.count > 0) { + types[r.value.type] = r.value.count; + } + } + } catch { + // Non-critical + } + + return NextResponse.json({ + healthy, + totalThoughts, + embeddingCoverage: totalThoughts > 0 ? "99.2%" : "N/A", + types, + topTopics: [], + sources, + apiKeyPrefix: apiKey.substring(0, 8), + }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to load status" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/api/thoughts/[id]/connections/route.ts b/dashboards/open-brain-dashboard-pro/app/api/thoughts/[id]/connections/route.ts new file mode 100644 index 000000000..ddec3bd71 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/api/thoughts/[id]/connections/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireSession, AuthError } from "@/lib/auth"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + let apiKey: string; + try { + ({ apiKey } = await requireSession()); + } catch (err) { + if (err instanceof AuthError) + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + throw err; + } + + const { id } = await params; + const API_URL = process.env.NEXT_PUBLIC_API_URL; + if (!API_URL) { + return NextResponse.json( + { error: "NEXT_PUBLIC_API_URL not configured" }, + { status: 500 } + ); + } + const excludeRestricted = + request.nextUrl.searchParams.get("exclude_restricted") !== "false"; + + try { + const res = await fetch( + `${API_URL}/thought/${id}/connections?exclude_restricted=${excludeRestricted}&limit=20`, + { + headers: { + "x-brain-key": apiKey, + "Content-Type": "application/json", + }, + } + ); + const data = await res.json(); + if (!res.ok) return NextResponse.json(data, { status: res.status }); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Failed to fetch connections" }, + { status: 500 } + ); + } +} diff --git a/dashboards/open-brain-dashboard-pro/app/audit/page.tsx b/dashboards/open-brain-dashboard-pro/app/audit/page.tsx new file mode 100644 index 000000000..463eb4d10 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/audit/page.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { TypeBadge } from "@/components/ThoughtCard"; +import { DeleteModal } from "@/components/DeleteModal"; +import type { Thought, BrowseResponse } from "@/lib/types"; + +export default function AuditPage() { + const [data, setData] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [showDelete, setShowDelete] = useState(false); + const [showFinalConfirm, setShowFinalConfirm] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(`/api/audit?page=${page}`); + if (!res.ok) throw new Error("Failed to load"); + const d = await res.json(); + setData(d); + } catch (err) { + setError(err instanceof Error ? err.message : "Load failed"); + } finally { + setLoading(false); + } + }, [page]); + + useEffect(() => { + load(); + }, [load]); + + const toggleSelect = (id: number) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const toggleAll = () => { + if (!data) return; + if (selected.size === data.data.length) { + setSelected(new Set()); + } else { + setSelected(new Set(data.data.map((t) => t.id))); + } + }; + + const handleBulkDelete = async () => { + try { + const res = await fetch("/api/audit/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: Array.from(selected) }), + }); + if (!res.ok) throw new Error("Delete failed"); + setSelected(new Set()); + setShowFinalConfirm(false); + setShowDelete(false); + load(); + } catch (err) { + setError(err instanceof Error ? err.message : "Delete failed"); + } + }; + + if (loading && !data) { + return ( +
+

Audit

+
+
+ Loading low-quality thoughts... +
+
+ ); + } + + const totalPages = data ? Math.ceil(data.total / data.per_page) : 0; + + return ( +
+
+
+

Audit

+

+ Review low quality thoughts (score < 30) + {data && ` | ${data.total.toLocaleString()} total`} +

+
+ {selected.size > 0 && ( + + )} +
+ + {error &&

{error}

} + + {data && ( +
+ + + + + + + + + + + {data.data.map((t: Thought) => ( + + + + + + + ))} + +
+ 0 && + selected.size === data.data.length + } + onChange={toggleAll} + className="accent-violet" + /> + ContentTypeScore
+ toggleSelect(t.id)} + className="accent-violet" + /> + + + {t.content.length > 100 + ? t.content.slice(0, 100) + "..." + : t.content} + + + + + {t.quality_score} +
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+
+ + +
+
+ )} + + {/* Two-step delete: first confirm count */} + {showDelete && !showFinalConfirm && ( + { + setShowDelete(false); + setShowFinalConfirm(true); + }} + onCancel={() => setShowDelete(false)} + /> + )} + {showFinalConfirm && ( + setShowFinalConfirm(false)} + /> + )} +
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/duplicates/page.tsx b/dashboards/open-brain-dashboard-pro/app/duplicates/page.tsx new file mode 100644 index 000000000..d92d2984d --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/duplicates/page.tsx @@ -0,0 +1,460 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { TypeBadge } from "@/components/ThoughtCard"; +import { DeleteModal } from "@/components/DeleteModal"; +import { formatDate } from "@/lib/format"; +import type { DuplicatePair } from "@/lib/types"; + +const PER_PAGE = 30; + +// Tracks resolution for a pair — "keep_a" = delete B, "keep_b" = delete A, "keep_both" = dismiss +type Selection = "keep_a" | "keep_b" | "keep_both"; + +export default function DuplicatesPage() { + const [pairs, setPairs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [threshold, setThreshold] = useState(0.85); + const [offset, setOffset] = useState(0); + const [resolving, setResolving] = useState(null); + const [confirmDelete, setConfirmDelete] = useState<{ + action: "keep_a" | "keep_b"; + pair: DuplicatePair; + } | null>(null); + + // Batch selection state: pairKey -> which side to keep + const [selections, setSelections] = useState>({}); + const [batchProcessing, setBatchProcessing] = useState(false); + const [confirmBatch, setConfirmBatch] = useState(false); + + const toggleSelection = (key: string, action: Selection) => { + setSelections((prev) => { + if (prev[key] === action) { + // Deselect if clicking the same side + const next = { ...prev }; + delete next[key]; + return next; + } + return { ...prev, [key]: action }; + }); + }; + + const clearSelections = () => setSelections({}); + + const selectedCount = Object.keys(selections).length; + + const processBatch = async () => { + setBatchProcessing(true); + setError(null); + const entries = Object.entries(selections); + const removedKeys: string[] = []; + + for (const [key, action] of entries) { + const pair = pairs.find((p) => pairKey(p) === key); + if (!pair) continue; + try { + const res = await fetch("/api/duplicates/resolve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action, + thought_id_a: pair.thought_id_a, + thought_id_b: pair.thought_id_b, + }), + }); + if (!res.ok) throw new Error(`Failed for pair ${key}`); + removedKeys.push(key); + } catch { + // Continue with remaining — partial success is fine + } + } + + // Remove resolved pairs from state + setPairs((prev) => prev.filter((p) => !removedKeys.includes(pairKey(p)))); + setSelections((prev) => { + const next = { ...prev }; + for (const k of removedKeys) delete next[k]; + return next; + }); + setBatchProcessing(false); + setConfirmBatch(false); + }; + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch( + `/api/duplicates?threshold=${threshold}&limit=${PER_PAGE}&offset=${offset}` + ); + if (!res.ok) throw new Error("Failed to load"); + const data = await res.json(); + setPairs(data.pairs ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : "Load failed"); + } finally { + setLoading(false); + } + }, [threshold, offset]); + + useEffect(() => { + load(); + }, [load]); + + const resolve = async ( + action: "keep_a" | "keep_b" | "keep_both", + pair: DuplicatePair + ) => { + const key = `${pair.thought_id_a}-${pair.thought_id_b}`; + setResolving(key); + try { + const res = await fetch("/api/duplicates/resolve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action, + thought_id_a: pair.thought_id_a, + thought_id_b: pair.thought_id_b, + }), + }); + if (!res.ok) throw new Error("Resolve failed"); + setPairs((prev) => + prev.filter( + (p) => + !( + p.thought_id_a === pair.thought_id_a && + p.thought_id_b === pair.thought_id_b + ) + ) + ); + } catch (err) { + setError(err instanceof Error ? err.message : "Resolve failed"); + } finally { + setResolving(null); + setConfirmDelete(null); + } + }; + + const pairKey = (p: DuplicatePair) => + `${p.thought_id_a}-${p.thought_id_b}`; + + if (loading && pairs.length === 0) { + return ( +
+

Duplicates

+
+
+ Searching for near-duplicates... +
+
+ ); + } + + return ( +
+
+
+

Duplicates

+

+ Semantic near-duplicates (similarity > {(threshold * 100).toFixed(0)}%) + {!loading && ` | ${pairs.length} pairs found`} +

+
+
+ + +
+
+ + {/* Batch action toolbar */} + {selectedCount > 0 && (() => { + const deleteCount = Object.values(selections).filter(s => s === "keep_a" || s === "keep_b").length; + const keepBothCount = Object.values(selections).filter(s => s === "keep_both").length; + return ( +
+ + {selectedCount} pair{selectedCount > 1 ? "s" : ""} selected + {deleteCount > 0 && keepBothCount > 0 && ( + + {" "}({deleteCount} to delete, {keepBothCount} to dismiss) + + )} + + + +
+ ); + })()} + + {error &&

{error}

} + + {pairs.length === 0 && !loading && ( +
+ No near-duplicates found at this threshold. +
+ )} + +
+ {pairs.map((pair) => { + const key = pairKey(pair); + const isResolving = resolving === key; + const sim = (pair.similarity * 100).toFixed(1); + + return ( +
+ {/* Header with similarity badge */} +
+ + {sim}% similar + +
+ +
+
+ + {/* Side-by-side content */} +
+ {/* Left: Thought A */} +
toggleSelection(key, "keep_a")} + > +
+
+ toggleSelection(key, "keep_a")} + onClick={(e) => e.stopPropagation()} + className="accent-emerald-500" + title="Keep this, delete the other" + /> + e.stopPropagation()} + > + #{pair.thought_id_a} + + +
+ + Q:{pair.quality_a ?? "—"} + +
+

+ {pair.content_a.length > 200 + ? pair.content_a.slice(0, 200) + "..." + : pair.content_a} +

+
+ +
+ {selections[key] === "keep_a" && ( + Keep + )} + {selections[key] === "keep_b" && ( + Delete + )} + +
+
+
+ + {/* Right: Thought B */} +
toggleSelection(key, "keep_b")} + > +
+
+ toggleSelection(key, "keep_b")} + onClick={(e) => e.stopPropagation()} + className="accent-emerald-500" + title="Keep this, delete the other" + /> + e.stopPropagation()} + > + #{pair.thought_id_b} + + +
+ + Q:{pair.quality_b ?? "—"} + +
+

+ {pair.content_b.length > 200 + ? pair.content_b.slice(0, 200) + "..." + : pair.content_b} +

+
+ +
+ {selections[key] === "keep_b" && ( + Keep + )} + {selections[key] === "keep_a" && ( + Delete + )} + +
+
+
+
+
+ ); + })} +
+ + {/* Pagination */} + {pairs.length > 0 && ( +
+

+ Showing {offset + 1}–{offset + pairs.length} +

+
+ + +
+
+ )} + + {/* Confirm single delete modal */} + {confirmDelete && ( + + resolve(confirmDelete.action, confirmDelete.pair) + } + onCancel={() => setConfirmDelete(null)} + /> + )} + + {/* Confirm batch resolve modal */} + {confirmBatch && (() => { + const deleteCount = Object.values(selections).filter(s => s === "keep_a" || s === "keep_b").length; + const keepBothCount = Object.values(selections).filter(s => s === "keep_both").length; + const parts: string[] = []; + if (deleteCount > 0) parts.push(`delete ${deleteCount} duplicate${deleteCount > 1 ? "s" : ""}`); + if (keepBothCount > 0) parts.push(`dismiss ${keepBothCount} pair${keepBothCount > 1 ? "s" : ""}`); + return ( + setConfirmBatch(false)} + /> + ); + })()} +
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/globals.css b/dashboards/open-brain-dashboard-pro/app/globals.css new file mode 100644 index 000000000..90a1d8f93 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/globals.css @@ -0,0 +1,51 @@ +@import "tailwindcss"; + +@theme inline { + --color-bg-primary: #0a0a0f; + --color-bg-surface: #111118; + --color-bg-elevated: #1a1a24; + --color-bg-hover: #222230; + --color-border: #2a2a3a; + --color-border-subtle: #1e1e2e; + --color-text-primary: #e8e8ef; + --color-text-secondary: #9090a8; + --color-text-muted: #606078; + --color-violet: #8b5cf6; + --color-violet-dim: #7c3aed; + --color-violet-glow: rgba(139, 92, 246, 0.15); + --color-violet-surface: rgba(139, 92, 246, 0.08); + --color-success: #34d399; + --color-warning: #fbbf24; + --color-danger: #f87171; + --color-info: #60a5fa; + --font-sans: var(--font-geist-sans), system-ui, sans-serif; + --font-mono: var(--font-geist-mono), monospace; +} + +body { + background: var(--color-bg-primary); + color: var(--color-text-primary); + font-family: var(--font-sans); +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: var(--color-bg-primary); +} +::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); +} + +/* Selection */ +::selection { + background: var(--color-violet-glow); + color: var(--color-text-primary); +} diff --git a/dashboards/open-brain-dashboard-pro/app/ingest/page.tsx b/dashboards/open-brain-dashboard-pro/app/ingest/page.tsx new file mode 100644 index 000000000..90d421cf2 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/ingest/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { AddToBrain } from "@/components/AddToBrain"; +import type { IngestionJob } from "@/lib/types"; +import { formatDate } from "@/lib/format"; + +const statusColor: Record = { + complete: "text-success", + dry_run_complete: "text-violet", + executing: "text-violet", + extracting: "text-warning", + pending: "text-warning", + failed: "text-danger", +}; + +export default function AddToBrainPage() { + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + + const loadJobs = useCallback(async () => { + try { + const res = await fetch("/api/ingest"); + if (!res.ok) throw new Error("Failed to load jobs"); + const data = await res.json(); + setJobs(data); + } catch { + // silently ignore — job list is secondary + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadJobs(); + }, [loadJobs]); + + return ( +
+
+

Add to Brain

+

+ Paste a thought, notes, or source text. Open Brain will decide + whether to save one thought or extract several. +

+
+ + {/* Unified input */} +
+ loadJobs()} + /> +
+ + {/* Job history */} +
+

Recent Activity

+ {loading ? ( +
+
+ Loading... +
+ ) : jobs.length === 0 ? ( +

+ No ingestion jobs yet. Add longer content to see extraction results + here. +

+ ) : ( +
+ {jobs.map((job) => ( +
+
+
+ + {job.source_label || `Job #${job.id}`} + + + {job.status.replace(/_/g, " ")} + +
+ + {formatDate(job.created_at)} + +
+
+ + Extracted:{" "} + + {job.extracted_count} + + + + Added:{" "} + {job.added_count} + + + Skipped:{" "} + + {job.skipped_count} + + + {job.revised_count > 0 && ( + + Revised:{" "} + {job.revised_count} + + )} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/layout.tsx b/dashboards/open-brain-dashboard-pro/app/layout.tsx new file mode 100644 index 000000000..6925dc0f1 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/layout.tsx @@ -0,0 +1,41 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; +import { Sidebar } from "@/components/Sidebar"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Open Brain", + description: "Open Brain second-brain dashboard", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + +
+
+ {children} +
+
+ + + ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/login/LoginForm.tsx b/dashboards/open-brain-dashboard-pro/app/login/LoginForm.tsx new file mode 100644 index 000000000..2cbf68e41 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/login/LoginForm.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useActionState } from "react"; + +export function LoginForm({ + action, +}: { + action: (formData: FormData) => Promise<{ error: string } | undefined>; +}) { + const [state, formAction, pending] = useActionState( + async (_prev: { error: string } | undefined, formData: FormData) => { + return await action(formData); + }, + undefined + ); + + return ( +
+
+ + +
+ + {state?.error && ( +

{state.error}

+ )} + + +
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/login/layout.tsx b/dashboards/open-brain-dashboard-pro/app/login/layout.tsx new file mode 100644 index 000000000..13df71a85 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/login/layout.tsx @@ -0,0 +1,8 @@ +export default function LoginLayout({ + children, +}: { + children: React.ReactNode; +}) { + // Login page renders without the sidebar (Sidebar checks pathname) + return <>{children}; +} diff --git a/dashboards/open-brain-dashboard-pro/app/login/page.tsx b/dashboards/open-brain-dashboard-pro/app/login/page.tsx new file mode 100644 index 000000000..a0f2d4f03 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/login/page.tsx @@ -0,0 +1,59 @@ +import { redirect } from "next/navigation"; +import { getSession } from "@/lib/auth"; +import { LoginForm } from "./LoginForm"; + +async function loginAction(formData: FormData) { + "use server"; + + const apiKey = formData.get("apiKey") as string; + if (!apiKey?.trim()) { + return { error: "API key is required" }; + } + + // Validate key against health endpoint + const apiUrl = process.env.NEXT_PUBLIC_API_URL; + try { + const res = await fetch(`${apiUrl}/health`, { + headers: { "x-brain-key": apiKey }, + }); + if (!res.ok) { + return { error: "Invalid API key or service unavailable" }; + } + } catch { + return { error: "Could not reach API. Check your connection." }; + } + + const session = await getSession(); + session.apiKey = apiKey; + session.loggedIn = true; + await session.save(); + + redirect("/"); +} + +export default async function LoginPage() { + const session = await getSession(); + if (session.loggedIn && session.apiKey) { + redirect("/"); + } + + return ( +
+
+
+
+ O +
+

+ Open Brain +

+

+ Enter your API key to continue +

+
+ + +
+
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/page.tsx b/dashboards/open-brain-dashboard-pro/app/page.tsx new file mode 100644 index 000000000..ab50bac1e --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/page.tsx @@ -0,0 +1,70 @@ +import { fetchStats, fetchThoughts } from "@/lib/api"; +import { requireSessionOrRedirect, getSession } from "@/lib/auth"; +import { StatsWidget } from "@/components/StatsWidget"; +import { ThoughtCard } from "@/components/ThoughtCard"; +import { AddToBrain } from "@/components/AddToBrain"; + +export const dynamic = "force-dynamic"; + +export default async function DashboardPage() { + const { apiKey } = await requireSessionOrRedirect(); + const session = await getSession(); + const excludeRestricted = !session.restrictedUnlocked; + + let stats, recent; + try { + [stats, recent] = await Promise.all([ + fetchStats(apiKey, undefined, excludeRestricted), + fetchThoughts(apiKey, { page: 1, per_page: 5, exclude_restricted: excludeRestricted }), + ]); + } catch (err) { + return ( +
+

Dashboard

+
+ Failed to load dashboard data. Check API connection. +
+ + {err instanceof Error ? err.message : "Unknown error"} + +
+
+ ); + } + + return ( +
+
+

Dashboard

+

+ Overview of your Open Brain +

+
+ + + + {/* Add to Brain */} +
+

Add to Brain

+

+ Paste a thought, notes, or source text. Open Brain decides whether to + save one thought or extract several. +

+ +
+ + {/* Recent activity */} +
+

Recent Activity

+
+ {recent.data.map((thought) => ( + + ))} + {recent.data.length === 0 && ( +

No thoughts yet.

+ )} +
+
+
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/search/page.tsx b/dashboards/open-brain-dashboard-pro/app/search/page.tsx new file mode 100644 index 000000000..f6cf22d69 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/search/page.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { SearchBar } from "@/components/SearchBar"; +import { TypeBadge } from "@/components/ThoughtCard"; +import Link from "next/link"; +import type { Thought } from "@/lib/types"; +import { formatDate } from "@/lib/format"; + +type SearchResult = Thought & { similarity?: number; rank?: number }; + +interface SearchState { + results: SearchResult[]; + total: number; + page: number; + totalPages: number; + mode: "semantic" | "text"; +} + +export default function SearchPage() { + const [state, setState] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastQuery, setLastQuery] = useState(""); + const [lastMode, setLastMode] = useState<"semantic" | "text">("semantic"); + + const doSearch = useCallback( + async (query: string, mode: "semantic" | "text", page: number = 1) => { + setLoading(true); + setError(null); + setLastQuery(query); + setLastMode(mode); + try { + const res = await fetch( + `/api/search?q=${encodeURIComponent(query)}&mode=${mode}&page=${page}` + ); + if (!res.ok) throw new Error("Search failed"); + const data = await res.json(); + setState({ + results: data.results || [], + total: data.total || 0, + page: data.page || 1, + totalPages: data.total_pages || 1, + mode, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Search failed"); + setState(null); + } finally { + setLoading(false); + } + }, + [] + ); + + const handleSearch = useCallback( + (query: string, mode: "semantic" | "text") => { + doSearch(query, mode, 1); + }, + [doSearch] + ); + + const goToPage = useCallback( + (page: number) => { + if (lastQuery) doSearch(lastQuery, lastMode, page); + }, + [doSearch, lastQuery, lastMode] + ); + + return ( +
+
+

Search

+

+ Search across your Open Brain +

+
+ + + + {loading && ( +
+
+ Searching... +
+ )} + + {error &&

{error}

} + + {state !== null && !loading && ( +
+

+ {state.total} result{state.total !== 1 ? "s" : ""} + {state.totalPages > 1 && ( + + {" "} + · Page {state.page} of {state.totalPages} + + )} +

+
+ {state.results.map((r) => ( + +
+ + {state.mode === "semantic" && r.similarity != null && ( + + {(r.similarity * 100).toFixed(1)}% match + + )} + + {formatDate(r.created_at)} + +
+

+ {r.content.length > 300 + ? r.content.slice(0, 300) + "..." + : r.content} +

+ + ))} + {state.results.length === 0 && ( +

No results found.

+ )} +
+ + {/* Pagination */} + {state.totalPages > 1 && ( +
+

+ Page {state.page} of {state.totalPages} ({state.total} results) +

+
+ {state.page > 1 && ( + + )} + {state.page < state.totalPages && ( + + )} +
+
+ )} +
+ )} +
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/settings/page.tsx b/dashboards/open-brain-dashboard-pro/app/settings/page.tsx new file mode 100644 index 000000000..6d04ae3cc --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/settings/page.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useState, useEffect } from "react"; + +interface BrainStatus { + healthy: boolean; + totalThoughts: number; + embeddingCoverage: string; + types: Record; + topTopics: Array<{ topic: string; count: number }>; + sources: Record; + apiKeyPrefix: string; +} + +export default function SettingsPage() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + try { + const res = await fetch("/api/settings/status"); + if (!res.ok) throw new Error("Failed to load brain status"); + const data: BrainStatus = await res.json(); + setStatus(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Load failed"); + } finally { + setLoading(false); + } + })(); + }, []); + + if (loading) { + return ( +
+

Settings

+
+
+ Loading brain status... +
+
+ ); + } + + if (error) { + return ( +
+

Settings

+
+ {error} +
+
+ ); + } + + if (!status) return null; + + const typeEntries = Object.entries(status.types).sort((a, b) => b[1] - a[1]); + const sourceEntries = Object.entries(status.sources).sort((a, b) => b[1] - a[1]); + + return ( +
+
+

Settings

+

+ Brain status and connection details +

+
+ + {/* Connection status */} +
+

+ Connection +

+
+
+ Status +
+
+ + {status.healthy ? "Connected" : "Disconnected"} + +
+
+
+ API Key +

+ {status.apiKeyPrefix}... +

+
+
+
+ + {/* Brain overview */} +
+

+ Your Brain +

+ + {status.totalThoughts === 0 ? ( +
+

+ Your brain is empty. Add your first thought to get started. +

+ + Add your first thought + +
+ ) : ( +
+ + + +
+ )} +
+ + {/* Type breakdown */} + {typeEntries.length > 0 && ( +
+

+ Thought Types +

+
+ {typeEntries.map(([type, count]) => { + const pct = Math.round((count / status.totalThoughts) * 100); + return ( +
+ + {type} + +
+
+
+ + {count.toLocaleString()} ({pct}%) + +
+ ); + })} +
+
+ )} + + {/* Source breakdown */} + {sourceEntries.length > 0 && ( +
+

+ Sources +

+
+ {sourceEntries.map(([source, count]) => ( +
+ + {source.replace(/_/g, " ")} + + + {count.toLocaleString()} + +
+ ))} +
+
+ )} + + {/* Top topics */} + {status.topTopics.length > 0 && ( +
+

+ Top Topics +

+
+ {status.topTopics.map(({ topic, count }) => ( + + {topic} + {count} + + ))} +
+
+ )} +
+ ); +} + +function StatCard({ label, value }: { label: string; value: string }) { + return ( +
+ {label} +

{value}

+
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/thoughts/[id]/page.tsx b/dashboards/open-brain-dashboard-pro/app/thoughts/[id]/page.tsx new file mode 100644 index 000000000..66ab6d1f8 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/thoughts/[id]/page.tsx @@ -0,0 +1,171 @@ +import { notFound } from "next/navigation"; +import { + fetchThought, + updateThought, + deleteThought, + ApiError, +} from "@/lib/api"; +import { requireSessionOrRedirect, getSession } from "@/lib/auth"; +import { TypeBadge } from "@/components/ThoughtCard"; +import { ThoughtEditor } from "@/components/ThoughtEditor"; +import { ThoughtDeleteButton } from "@/components/ThoughtDeleteButton"; +import { ConnectionsPanel } from "@/components/ConnectionsPanel"; +import { FormattedDate } from "@/components/FormattedDate"; +import Link from "next/link"; + +export const dynamic = "force-dynamic"; + +export default async function ThoughtDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { apiKey } = await requireSessionOrRedirect(); + const session = await getSession(); + const excludeRestricted = !session.restrictedUnlocked; + const { id } = await params; + const thoughtId = parseInt(id, 10); + if (isNaN(thoughtId)) notFound(); + + let thought; + try { + thought = await fetchThought(apiKey, thoughtId, excludeRestricted); + } catch (err) { + if (err instanceof ApiError && err.status === 403) { + return ( +
+
🔒
+

Restricted Content

+

+ This thought is classified as restricted. Unlock restricted content using the lock icon in the sidebar to view it. +

+ + Back to Thoughts + +
+ ); + } + notFound(); + } + + const meta = thought.metadata || {}; + const topics = (meta.topics as string[]) || []; + const tags = (meta.tags as string[]) || []; + + async function editAction(formData: FormData) { + "use server"; + const { apiKey } = await requireSessionOrRedirect(); + const content = formData.get("content") as string; + const type = formData.get("type") as string; + const importance = parseInt(formData.get("importance") as string, 10); + await updateThought(apiKey, thoughtId, { content, type, importance }); + } + + async function deleteAction() { + "use server"; + const { apiKey } = await requireSessionOrRedirect(); + await deleteThought(apiKey, thoughtId); + } + + return ( +
+ {/* Header */} +
+
+
+ + + ID: {thought.id} + + {thought.uuid && ( + + UUID: {thought.uuid} + + )} + + Importance: {thought.importance} + + {thought.quality_score > 0 && ( + + Quality: {thought.quality_score} + + )} +
+

+ Created + {thought.source_type && ` | Source: ${thought.source_type}`} + {thought.sensitivity_tier && + thought.sensitivity_tier !== "standard" && + ` | Sensitivity: ${thought.sensitivity_tier}`} +

+
+ +
+ + {/* Content + Edit */} + + + {/* Metadata panel */} + {(topics.length > 0 || + tags.length > 0 || + Object.keys(meta).length > 0) && ( +
+

+ Metadata +

+ {topics.length > 0 && ( +
+ Topics: +
+ {topics.map((t) => ( + + {t} + + ))} +
+
+ )} + {tags.length > 0 && ( +
+ Tags: +
+ {tags.map((t) => ( + + {t} + + ))} +
+
+ )} + {typeof meta.summary === "string" && ( +
+ Summary: + + {meta.summary} + +
+ )} +
+ )} + + {/* Connections */} + 0 || + ((meta.people as string[]) || []).length > 0 + } + /> + +
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/app/thoughts/page.tsx b/dashboards/open-brain-dashboard-pro/app/thoughts/page.tsx new file mode 100644 index 000000000..8b323d312 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/app/thoughts/page.tsx @@ -0,0 +1,155 @@ +import Link from "next/link"; +import { fetchThoughts } from "@/lib/api"; +import { requireSessionOrRedirect, getSession } from "@/lib/auth"; +import { TypeBadge } from "@/components/ThoughtCard"; +import { ThoughtsFilter } from "@/components/ThoughtsFilter"; +import { FormattedDate } from "@/components/FormattedDate"; + +export const dynamic = "force-dynamic"; + +const TYPES = [ + "idea", + "task", + "person_note", + "reference", + "decision", + "lesson", + "meeting", + "journal", +]; + +export default async function ThoughtsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const { apiKey } = await requireSessionOrRedirect(); + const session = await getSession(); + const excludeRestricted = session.restrictedUnlocked !== true; + const params = await searchParams; + const page = parseInt(params.page || "1", 10); + const type = params.type || ""; + const source_type = params.source_type || ""; + const importance_min = params.importance_min + ? parseInt(params.importance_min, 10) + : undefined; + + let data; + try { + data = await fetchThoughts(apiKey, { + page, + per_page: 25, + type: type || undefined, + source_type: source_type || undefined, + importance_min, + exclude_restricted: excludeRestricted, + }); + } catch (err) { + return ( +
+

Thoughts

+

+ Failed to load thoughts.{" "} + {err instanceof Error ? err.message : ""} +

+
+ ); + } + + const totalPages = Math.ceil(data.total / data.per_page); + + function pageUrl(p: number) { + const sp = new URLSearchParams(); + sp.set("page", String(p)); + if (type) sp.set("type", type); + if (source_type) sp.set("source_type", source_type); + if (importance_min) sp.set("importance_min", String(importance_min)); + return `/thoughts?${sp.toString()}`; + } + + return ( +
+
+

Thoughts

+

+ {data.total.toLocaleString()} total thoughts +

+
+ + {/* Filters */} + + + {/* Table */} +
+ + + + + + + + + + + {data.data.map((t) => ( + + + + + + + ))} + +
ContentTypeImp.Date
+ + {t.content.length > 120 + ? t.content.slice(0, 120) + "..." + : t.content} + + + + {t.importance} + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} +

+
+ {page > 1 && ( + + Previous + + )} + {page < totalPages && ( + + Next + + )} +
+
+ )} +
+ ); +} diff --git a/dashboards/open-brain-dashboard-pro/components/AddToBrain.tsx b/dashboards/open-brain-dashboard-pro/components/AddToBrain.tsx new file mode 100644 index 000000000..ddfcf4a02 --- /dev/null +++ b/dashboards/open-brain-dashboard-pro/components/AddToBrain.tsx @@ -0,0 +1,465 @@ +"use client"; + +import { useState, useCallback } from "react"; +import type { + AddToBrainMode, + AddToBrainResult, + IngestionItem, + IngestionJobDetail, +} from "@/lib/types"; + +// ── Display maps ─────────────────────────────────────────────────────────── + +interface AddToBrainProps { + /** Textarea row count (default 4) */ + rows?: number; + /** Show mode selector (default false — auto only) */ + showModeControl?: boolean; + /** Show inline job detail + execute (default false) */ + showJobDetail?: boolean; + /** Callback after successful add */ + onSuccess?: (result: AddToBrainResult) => void; +} + +const MODES: { value: AddToBrainMode; label: string; description: string }[] = [ + { + value: "auto", + label: "Auto", + description: "Open Brain decides the best path", + }, + { + value: "single", + label: "Save as one thought", + description: "Save the entire input as a single thought", + }, + { + value: "extract", + label: "Extract multiple thoughts", + description: "Run smart-ingest to extract atomic thoughts", + }, +]; + +const ACTION_COLORS: Record = { + add: "text-success", + skip: "text-text-muted", + create_revision: "text-info", + append_evidence: "text-violet", +}; + +const ACTION_LABELS: Record = { + add: "Add", + skip: "Skip", + create_revision: "Revise", + append_evidence: "Append", +}; + +const REASON_LABELS: Record = { + quality_gate_low_importance: "Filtered: low importance", + quality_gate_short_content: "Filtered: too short", + fingerprint_match: "Already exists", + semantic_duplicate: "Near-duplicate", + duplicate_within_job: "Duplicate in batch", + no_semantic_match: "New thought", + existing_is_richer: "Existing thought is richer", + new_has_more_info: "Has more detail than existing", +}; + +const IMPORTANCE_COLORS: Record = { + 0: "bg-bg-surface text-text-muted/60 border-border", + 1: "bg-bg-surface text-text-muted border-border", + 2: "bg-bg-surface text-text-muted border-border", + 3: "bg-bg-surface text-text-secondary border-border", + 4: "bg-amber-500/10 text-amber-400 border-amber-500/20", + 5: "bg-rose-500/10 text-rose-400 border-rose-500/20", + 6: "bg-red-500/15 text-red-400 border-red-500/30", +}; + +/** Renders a single ingestion item card with type, action, importance, tags, snippet, and thought link. */ +function ItemCard({ item, jobStatus }: { item: IngestionItem; jobStatus: string }) { + const [snippetOpen, setSnippetOpen] = useState(false); + const type = item.meta.type ?? "unknown"; + const importance = item.meta.importance; + const tags = item.meta.tags ?? []; + const snippet = item.meta.source_snippet; + const friendlyReason = item.reason ? (REASON_LABELS[item.reason] ?? item.reason) : null; + + return ( +
+ {/* Row 1: type badge + importance + action + reason */} +
+ + {type} + + {importance != null && ( + + {importance} + + )} + + {ACTION_LABELS[item.action] || item.action} + + {friendlyReason && ( + + — {friendlyReason} + + )} + {/* Thought link after execution */} + {item.result_thought_id && jobStatus === "complete" && ( + + View thought → + + )} +
+ + {/* Row 2: content (up to 280 chars) */} +

+ {item.content.length > 280 + ? item.content.slice(0, 277) + "..." + : item.content} +

+ + {/* Row 3: tags */} + {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + {/* Row 4: source snippet (collapsible) */} + {snippet && ( +
+ + {snippetOpen && ( +
+ {snippet} +
+ )} +
+ )} +
+ ); +} + +export function AddToBrain({ + rows = 4, + showModeControl = false, + showJobDetail = false, + onSuccess, +}: AddToBrainProps) { + const [text, setText] = useState(""); + const [mode, setMode] = useState("auto"); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(false); + const [dryRun, setDryRun] = useState(false); + + // Job detail state (only used when showJobDetail is true) + const [jobDetail, setJobDetail] = useState(null); + const [loadingDetail, setLoadingDetail] = useState(false); + const [executing, setExecuting] = useState(false); + const [executeError, setExecuteError] = useState(null); + + const fetchJobDetail = useCallback(async (jobId: number) => { + setLoadingDetail(true); + try { + const res = await fetch(`/api/ingest/${jobId}`); + if (!res.ok) throw new Error("Failed to load job detail"); + const data: IngestionJobDetail = await res.json(); + setJobDetail(data); + } catch { + // Non-critical — the job link still works + } finally { + setLoadingDetail(false); + } + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!text.trim() || submitting) return; + + setSubmitting(true); + setError(null); + setResult(null); + setJobDetail(null); + setExecuteError(null); + + try { + const body: Record = { text: text.trim(), mode }; + if (showJobDetail && dryRun) { + body.dry_run = true; + } + + const res = await fetch("/api/ingest", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error( + (data as Record).error || "Failed to add" + ); + } + const data: AddToBrainResult = await res.json(); + setResult(data); + setText(""); + setMode("auto"); + onSuccess?.(data); + + // Auto-fetch job detail if enabled and we got a job_id + if (showJobDetail && data.job_id) { + fetchJobDetail(data.job_id); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setSubmitting(false); + } + }; + + const handleExecute = async () => { + if (!jobDetail || executing) return; + setExecuting(true); + setExecuteError(null); + + try { + const res = await fetch(`/api/ingest/${jobDetail.job.id}/execute`, { + method: "POST", + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error( + (data as Record).error || "Execution failed" + ); + } + // Refresh job detail to show updated status + await fetchJobDetail(jobDetail.job.id); + // Update the result message + setResult((prev) => + prev + ? { ...prev, status: "complete", message: "Thoughts committed to brain" } + : prev + ); + } catch (err) { + setExecuteError( + err instanceof Error ? err.message : "Execution failed" + ); + } finally { + setExecuting(false); + } + }; + + return ( +
+
+