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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions dashboards/open-brain-dashboard-pro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,21 @@ If your brain runs the CRM truth layer (the `crm-core` + `crm-engagement` schema

The Contacts and Proposals nav entries, and the open-proposals badge on the sidebar, appear **only when the brain exposes the `/crm` surface**. The dashboard probes for it once at login and caches the result in the session, so a brain without the CRM layer never shows these entries and behaves exactly as before. Each CRM read also degrades on its own: if an individual route is missing or errors, that panel renders empty instead of blanking the page.

## Wiki (optional)

If your brain runs the persistent-wiki layer (the `schemas/wiki-pages` schema and the `/wiki/*` routes on `open-brain-rest`), the dashboard grows a wiki surface:

| Page | What you get |
|------|--------------|
| **Wiki** (`/wiki`) | Page list filtered by kind (topic / entity / autobiography / custom), with section counts and a "new page" form. |
| **Wiki page** (`/wiki/:slug`) | Sections in display order, each with its markdown body rendered sanitized, an origin chip (yours / generated), a lock toggle, and an evidence chip listing supporting thought ids. Edit a section inline (your edit takes ownership); add a section; archive the page. When a machine writer proposes an update to a section you own, a review panel shows the current body against the proposed draft with **accept / reject**. |

Wiki pages are reachable at `/wiki` (a dedicated sidebar entry lands in a later change). The dashboard probes for the `/wiki` surface once at login and caches the result in the session. When the schema is absent — the probe comes back negative, or `GET /wiki/pages` returns 404 — the page renders a short inline notice ("Wiki schema not installed — apply `schemas/wiki-pages` to enable") instead of an error, so a brain without the wiki layer behaves exactly as before.

Archived pages are hidden from the list but stay fetchable — and editable — by slug; unarchive is a future gateway addition.

Section bodies are agent-writable over MCP, so the dashboard treats them as untrusted: markdown is rendered with `react-markdown` and **no raw-HTML plugin**, so any embedded HTML is shown as text and never executed.

## Screenshots

Screenshots go in `docs/screenshots/` and should be referenced from this README once you add them.
Expand Down Expand Up @@ -98,6 +113,7 @@ The dashboard calls these endpoints on your Open Brain REST gateway (all authent
| `/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 |
| `/crm/*` (contacts, proposals, notes, tasks, important-dates, timeline, history, …) | GET / POST / PATCH | Contacts, Proposals, contact detail panels | Optional — CRM surface is hidden unless `/crm` is detected at login |
| `/wiki/*` (pages, pages/:slug, sections/:id/accept-pending, reject-pending, lock, …) | GET / POST / PUT / DELETE | Wiki list, wiki page, section edit / lock / draft review | Optional — wiki surface degrades to an inline notice unless `/wiki` is detected at login |

> **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.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { archiveWikiPage, ApiError } from "@/lib/api";
import { requireSession, AuthError } from "@/lib/auth";

// DELETE /api/wiki/pages/:slug — archive a page (soft delete).
// Proxies to DELETE /wiki/pages/:slug, which sets status='archived'.
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
let apiKey: string;
try {
({ apiKey } = await requireSession());
} catch (err) {
if (err instanceof AuthError)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
throw err;
}

const { slug } = await params;
if (!slug) {
return NextResponse.json({ error: "Missing slug" }, { status: 400 });
}

try {
const result = await archiveWikiPage(apiKey, slug);
return NextResponse.json(result);
} catch (err) {
if (err instanceof ApiError) {
console.error("[wiki/page:delete] upstream", err.status, err.upstreamBody);
return NextResponse.json({ error: "Upstream error" }, { status: err.status });
}
console.error("[wiki/page:delete]", err);
return NextResponse.json({ error: "Failed to archive page" }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { writeWikiSection, ApiError } from "@/lib/api";
import { requireSession, AuthError } from "@/lib/auth";

// PUT /api/wiki/pages/:slug/sections/:sectionKey — create or edit a section.
// Proxies to PUT /wiki/pages/:slug/sections/:sectionKey. REST edits are
// manual-origin: the first human edit transfers ownership of the section.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ slug: string; sectionKey: string }> }
) {
// Auth BEFORE body parse — unauthed requests get 401, not 400 (house rule).
let apiKey: string;
try {
({ apiKey } = await requireSession());
} catch (err) {
if (err instanceof AuthError)
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
throw err;
}

const { slug, sectionKey } = await params;
if (!slug || !sectionKey) {
return NextResponse.json({ error: "Missing slug or section key" }, { status: 400 });
}

try {
const body = (await request.json()) as {
body_md?: unknown;
heading?: unknown;
display_order?: unknown;
};

// body_md is required but may legitimately be an empty string (clears the
// section). Only reject when it is missing or not a string.
if (typeof body.body_md !== "string") {
return NextResponse.json({ error: "body_md is required" }, { status: 400 });
}
const heading =
typeof body.heading === "string" ? body.heading : undefined;

let displayOrder: number | undefined;
if (body.display_order !== undefined && body.display_order !== null) {
const n = Number(body.display_order);
if (!Number.isInteger(n)) {
return NextResponse.json({ error: "display_order must be an integer" }, { status: 400 });
}
displayOrder = n;
}

const result = await writeWikiSection(apiKey, slug, sectionKey, {
body_md: body.body_md,
heading,
display_order: displayOrder,
});
return NextResponse.json(result);
} catch (err) {
if (err instanceof ApiError) {
console.error("[wiki/section:put] upstream", err.status, err.upstreamBody);
return NextResponse.json({ error: "Upstream error" }, { status: err.status });
}
console.error("[wiki/section:put]", err);
return NextResponse.json({ error: "Failed to save section" }, { status: 500 });
}
}
59 changes: 59 additions & 0 deletions dashboards/open-brain-dashboard-pro/app/api/wiki/pages/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { createWikiPage, ApiError } from "@/lib/api";
import { requireSession, AuthError } from "@/lib/auth";
import type { WikiPageKind } from "@/lib/types";

const PAGE_KINDS: WikiPageKind[] = ["topic", "entity", "autobiography", "custom"];

// POST /api/wiki/pages — create a wiki page. Proxies to POST /wiki/pages.
export async function POST(request: NextRequest) {
// Auth BEFORE body parse — unauthed requests get 401, not 400 (house rule).
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()) as {
slug?: unknown;
title?: unknown;
page_kind?: unknown;
metadata?: unknown;
};

const slug = typeof body.slug === "string" ? body.slug.trim() : "";
const title = typeof body.title === "string" ? body.title.trim() : "";
if (!slug) return NextResponse.json({ error: "slug is required" }, { status: 400 });
if (!title) return NextResponse.json({ error: "title is required" }, { status: 400 });

// page_kind is optional; when present it must be one of the allowed kinds so a
// bad value never reaches the DB CHECK constraint as a 500.
let pageKind: WikiPageKind | undefined;
if (body.page_kind !== undefined && body.page_kind !== "") {
if (typeof body.page_kind !== "string" || !PAGE_KINDS.includes(body.page_kind as WikiPageKind)) {
return NextResponse.json({ error: "Invalid page_kind" }, { status: 400 });
}
pageKind = body.page_kind as WikiPageKind;
}

const metadata =
body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata)
? (body.metadata as Record<string, unknown>)
: undefined;

const result = await createWikiPage(apiKey, { slug, title, page_kind: pageKind, metadata });
return NextResponse.json(result);
} catch (err) {
if (err instanceof ApiError) {
// Log the full upstream body server-side; never render it to the client.
console.error("[wiki/pages] upstream", err.status, err.upstreamBody);
return NextResponse.json({ error: "Upstream error" }, { status: err.status });
}
console.error("[wiki/pages]", err);
return NextResponse.json({ error: "Failed to create page" }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { acceptWikiPending, ApiError } from "@/lib/api";
import { requireSession, AuthError } from "@/lib/auth";

// POST /api/wiki/sections/:id/accept-pending — promote a parked machine draft to
// the live body. Proxies to POST /wiki/sections/:id/accept-pending.
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;
if (!id) {
return NextResponse.json({ error: "Missing section id" }, { status: 400 });
}

try {
const result = await acceptWikiPending(apiKey, id);
return NextResponse.json(result);
} catch (err) {
if (err instanceof ApiError) {
console.error("[wiki/section:accept] upstream", err.status, err.upstreamBody);
return NextResponse.json({ error: "Upstream error" }, { status: err.status });
}
console.error("[wiki/section:accept]", err);
return NextResponse.json({ error: "Failed to accept draft" }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { setWikiSectionLock, ApiError } from "@/lib/api";
import { requireSession, AuthError } from "@/lib/auth";

// POST /api/wiki/sections/:id/lock — lock or unlock a section. A locked section
// only ever receives pending drafts from machine writers. Proxies to
// POST /wiki/sections/:id/lock with { locked }.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
// Auth BEFORE body parse — unauthed requests get 401, not 400 (house rule).
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;
if (!id) {
return NextResponse.json({ error: "Missing section id" }, { status: 400 });
}

try {
const body = (await request.json()) as { locked?: unknown };
if (typeof body.locked !== "boolean") {
return NextResponse.json({ error: "locked must be a boolean" }, { status: 400 });
}

const result = await setWikiSectionLock(apiKey, id, body.locked);
return NextResponse.json(result);
} catch (err) {
if (err instanceof ApiError) {
console.error("[wiki/section:lock] upstream", err.status, err.upstreamBody);
return NextResponse.json({ error: "Upstream error" }, { status: err.status });
}
console.error("[wiki/section:lock]", err);
return NextResponse.json({ error: "Failed to update lock" }, { status: 500 });
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { rejectWikiPending, ApiError } from "@/lib/api";
import { requireSession, AuthError } from "@/lib/auth";

// POST /api/wiki/sections/:id/reject-pending — discard a parked machine draft,
// leaving the live body untouched. Proxies to POST /wiki/sections/:id/reject-pending.
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;
if (!id) {
return NextResponse.json({ error: "Missing section id" }, { status: 400 });
}

try {
const result = await rejectWikiPending(apiKey, id);
return NextResponse.json(result);
} catch (err) {
if (err instanceof ApiError) {
console.error("[wiki/section:reject] upstream", err.status, err.upstreamBody);
return NextResponse.json({ error: "Upstream error" }, { status: err.status });
}
console.error("[wiki/section:reject]", err);
return NextResponse.json({ error: "Failed to reject draft" }, { status: 500 });
}
}
7 changes: 6 additions & 1 deletion dashboards/open-brain-dashboard-pro/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { checkHealth, crmAvailable, ApiError } from "@/lib/api";
import { checkHealth, crmAvailable, wikiAvailable, ApiError } from "@/lib/api";
import { LoginForm } from "./LoginForm";

async function loginAction(formData: FormData) {
Expand Down Expand Up @@ -29,10 +29,15 @@ async function loginAction(formData: FormData) {
// Never fatal: a brain without the crm-core schema simply gets crmEnabled=false.
const crmEnabled = await crmAvailable(key);

// Same for the optional wiki surface. Never fatal: a brain without the
// wiki-pages schema returns 404 on /wiki/pages, so this comes back false.
const wikiEnabled = await wikiAvailable(key);

const session = await getSession();
session.apiKey = key;
session.loggedIn = true;
session.crmEnabled = crmEnabled;
session.wikiEnabled = wikiEnabled;
await session.save();

redirect("/");
Expand Down
Loading
Loading