Skip to content
Open
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
2 changes: 1 addition & 1 deletion dashboards/open-brain-dashboard-next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ Or connect the folder to Vercel via the dashboard. Set the environment variables

### Step 5 (alternative): Deploy to Cloudflare Workers (optional)

If you're already on Cloudflare for the [`open-brain-rest`](../../integrations/cloudflare-rest-worker/) gateway, you can host the dashboard on the same platform via the [`@opennextjs/cloudflare`](https://opennext.js.org/cloudflare) adapter. The older `@cloudflare/next-on-pages` adapter caps at Next 15.5.x and doesn't support this dashboard's Next 16.
If you're already on Cloudflare for the [`open-brain-rest`](../../integrations/open-brain-rest/) gateway, you can host the dashboard on the same platform via the [`@opennextjs/cloudflare`](https://opennext.js.org/cloudflare) adapter. The older `@cloudflare/next-on-pages` adapter caps at Next 15.5.x and doesn't support this dashboard's Next 16.

The repo ships the two config files this needs out of the box (`open-next.config.ts` and `wrangler.jsonc`); rename the Worker in `wrangler.jsonc` if you want something other than `ob-dashboard`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function AddToBrain({
const [executing, setExecuting] = useState(false);
const [executeError, setExecuteError] = useState<string | null>(null);

const fetchJobDetail = useCallback(async (jobId: number) => {
const fetchJobDetail = useCallback(async (jobId: string) => {
setLoadingDetail(true);
try {
const res = await fetch(`/api/ingest/${jobId}`);
Expand Down
2 changes: 1 addition & 1 deletion dashboards/open-brain-dashboard-next/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export async function triggerIngest(
apiKey: string,
text: string,
opts?: { dry_run?: boolean }
): Promise<{ job_id: number; status: string }> {
): Promise<{ job_id: string; status: string }> {
return apiFetch(apiKey, "/ingest", {
method: "POST",
body: JSON.stringify({ text, ...opts }),
Expand Down
8 changes: 4 additions & 4 deletions dashboards/open-brain-dashboard-next/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export interface Reflection {
}

export interface IngestionJob {
id: number;
id: string;
source_label: string;
status: string;
extracted_count: number;
Expand Down Expand Up @@ -142,8 +142,8 @@ export interface ReflectionInput {
}

export interface IngestionItem {
id: number | string;
job_id: number;
id: string;
job_id: string;
content: string;
type: string;
fingerprint: string;
Expand All @@ -164,7 +164,7 @@ export type AddToBrainMode = "auto" | "single" | "extract";
export interface AddToBrainResult {
path: "single" | "extract";
thought_id?: string;
job_id?: number;
job_id?: string;
type?: string;
status?: string;
extracted_count?: number | null;
Expand Down
13 changes: 13 additions & 0 deletions dashboards/open-brain-dashboard-pro/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ Seven server-rendered pages backed by iron-session auth and the Open Brain REST
| **Ingest** (`/ingest`) | Smart-ingest UI with dry-run preview, extracted-item cards, execute button, and job history. |
| **Settings** (`/settings`) | Connection status, thought type breakdown, top topics, and masked API key prefix. |

## CRM (optional)

If your brain runs the CRM truth layer (the `crm-core` + `crm-engagement` schemas and the `/crm/*` routes on `open-brain-rest`), the dashboard grows a contacts surface:

| Page | What you get |
|------|--------------|
| **Contacts** (`/contacts`) | Searchable contact list and a "new contact" form. |
| **Contact** (`/contacts/:id`) | Editable fact panel with per-field origin, locks, and evidence; contact methods and aliases; this contact's open proposals with accept/reject; a notes / tasks / important-dates panel; and an activity view with a merged timeline and the raw change log. |
| **Proposals** (`/proposals`) | Inbox of machine-suggested field changes filtered by status, with per-row accept/reject and bulk accept/reject for a whole import run. |

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.

## Screenshots

Screenshots go in `docs/screenshots/` and should be referenced from this README once you add them.
Expand Down Expand Up @@ -85,6 +97,7 @@ The dashboard calls these endpoints on your Open Brain REST gateway (all authent
| `/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 |
| `/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 |

> **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
Expand Up @@ -16,9 +16,11 @@ export async function POST(

const { id } = await params;

// WR-04 / BL-03: Validate id is a positive integer before forwarding
const idNum = Number(id);
if (!Number.isInteger(idNum) || idNum <= 0) {
// WR-04 / BL-03: Validate id is a UUID before forwarding. OB1 ingestion job
// ids are UUIDs, not integers — the old positive-integer check rejected them.
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_RE.test(id)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}

Expand All @@ -34,7 +36,7 @@ export async function POST(
// BL-03: Re-verify the session owns this job by fetching it first.
// The REST gateway filters by the session's x-brain-key, so a 404/403 here
// indicates the job is not visible to the caller.
const verifyRes = await fetch(`${API_URL}/ingestion-jobs/${idNum}`, {
const verifyRes = await fetch(`${API_URL}/ingestion-jobs/${id}`, {
headers: { "x-brain-key": apiKey, "Content-Type": "application/json" },
});
if (!verifyRes.ok) {
Expand All @@ -43,7 +45,7 @@ export async function POST(
// of a misleading "denied" message.
if (verifyRes.status >= 500) {
console.error(
`[ingest/[id]/execute] preflight upstream 5xx for job ${idNum}:`,
`[ingest/[id]/execute] preflight upstream 5xx for job ${id}:`,
verifyRes.status
);
return NextResponse.json(
Expand All @@ -62,7 +64,7 @@ export async function POST(
);
}

const res = await fetch(`${API_URL}/ingestion-jobs/${idNum}/execute`, {
const res = await fetch(`${API_URL}/ingestion-jobs/${id}/execute`, {
method: "POST",
headers: { "x-brain-key": apiKey, "Content-Type": "application/json" },
});
Expand Down
18 changes: 10 additions & 8 deletions dashboards/open-brain-dashboard-pro/app/api/ingest/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ function normalizeItem(raw: Record<string, unknown>): IngestionItem {
};

return {
id: raw.id as number,
job_id: raw.job_id as number,
id: raw.id as string,
job_id: raw.job_id as string,
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,
matched_thought_id: (raw.matched_thought_id as string) ?? null,
similarity_score: raw.similarity_score != null ? Number(raw.similarity_score) : null,
result_thought_id: (raw.result_thought_id as number) ?? null,
result_thought_id: (raw.result_thought_id as string) ?? null,
meta: parsedMeta,
};
}
Expand All @@ -41,9 +41,11 @@ export async function GET(

const { id } = await params;

// WR-04: Validate id is a positive integer before forwarding
const idNum = Number(id);
if (!Number.isInteger(idNum) || idNum <= 0) {
// WR-04: Validate id is a UUID before forwarding. OB1 ingestion job ids are
// UUIDs, not integers — the old positive-integer check rejected them.
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!UUID_RE.test(id)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
Comment on lines +46 to 49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Let Dashboard Pro forward numeric ingest job IDs

Dashboard Pro now rejects /api/ingest/1 locally, but the committed smart-ingest schema still creates numeric ingestion_jobs.id values and the REST gateway still exposes /ingestion-jobs/(\d+) (integrations/rest-api/index.ts:325-336). When /ingest returns a numeric dry-run job_id, the AddToBrain preview calls this route and gets Invalid id before it can fetch details or reach the execute route, so numeric job ids need to remain valid unless the backend schema/routes are changed together.

Useful? React with 👍 / 👎.

}

Expand All @@ -56,7 +58,7 @@ export async function GET(
}

try {
const res = await fetch(`${API_URL}/ingestion-jobs/${idNum}`, {
const res = await fetch(`${API_URL}/ingestion-jobs/${id}`, {
headers: { "x-brain-key": apiKey, "Content-Type": "application/json" },
});
const data = await res.json();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"use client";

import { useActionState, useState } from "react";

// The server action returns one of these; `undefined` is the initial state.
export type AddMethodResult = { ok: true } | { error: string } | undefined;

// Common method types. The truth layer accepts arbitrary strings, so "other"
// falls back to a free-text input for anything not in the list.
const METHOD_TYPES = ["email", "phone", "address", "handle", "url", "other"];

export function AddMethodForm({
contactId,
action,
}: {
contactId: string;
action: (prev: AddMethodResult, formData: FormData) => Promise<AddMethodResult>;
}) {
const [open, setOpen] = useState(false);
const [type, setType] = useState("email");
const [state, formAction, pending] = useActionState(
async (prev: AddMethodResult, formData: FormData) => {
const result = await action(prev, formData);
if (result && "ok" in result && result.ok) {
setOpen(false);
setType("email");
}
return result;
},
undefined
);

if (!open) {
return (
<div className="px-4 py-3 border-t border-border">
<button
type="button"
onClick={() => setOpen(true)}
className="text-xs text-text-muted hover:text-violet transition-colors"
>
+ Add method
</button>
</div>
);
}

return (
<form action={formAction} className="px-4 py-3 border-t border-border space-y-3">
<input type="hidden" name="id" value={contactId} />
<div className="flex flex-wrap items-end gap-2">
<div>
<label htmlFor="method_type" className="block text-text-muted text-xs uppercase tracking-wider mb-1">
Type
</label>
<select
id="method_type"
name="method_type"
value={type}
onChange={(e) => setType(e.target.value)}
className="px-3 py-1.5 bg-bg-elevated border border-border rounded text-text-primary text-sm focus:outline-none focus:border-violet focus:ring-1 focus:ring-violet/30 transition"
>
{METHOD_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{type === "other" && (
<div>
<label htmlFor="method_type_custom" className="block text-text-muted text-xs uppercase tracking-wider mb-1">
Custom type
</label>
<input
id="method_type_custom"
name="method_type_custom"
placeholder="e.g. fax"
className="px-3 py-1.5 bg-bg-elevated border border-border rounded text-text-primary text-sm placeholder-text-muted focus:outline-none focus:border-violet focus:ring-1 focus:ring-violet/30 transition"
/>
</div>
)}
<div className="flex-1 min-w-[12rem]">
<label htmlFor="value" className="block text-text-muted text-xs uppercase tracking-wider mb-1">
Value
</label>
<input
id="value"
name="value"
required
placeholder="ada@example.com"
className="w-full px-3 py-1.5 bg-bg-elevated border border-border rounded text-text-primary text-sm placeholder-text-muted focus:outline-none focus:border-violet focus:ring-1 focus:ring-violet/30 transition"
/>
</div>
<div>
<label htmlFor="label" className="block text-text-muted text-xs uppercase tracking-wider mb-1">
Label
</label>
<input
id="label"
name="label"
placeholder="work (optional)"
className="px-3 py-1.5 bg-bg-elevated border border-border rounded text-text-primary text-sm placeholder-text-muted focus:outline-none focus:border-violet focus:ring-1 focus:ring-violet/30 transition"
/>
</div>
</div>

<label className="flex items-center gap-2 text-text-secondary text-sm">
<input type="checkbox" name="is_primary" value="true" className="accent-violet" />
Primary
</label>

{state && "error" in state && <p className="text-danger text-xs">{state.error}</p>}

<div className="flex items-center gap-2">
<button
type="submit"
disabled={pending}
className="text-xs px-3 py-1.5 rounded bg-violet hover:bg-violet-dim text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{pending ? "Adding…" : "Add method"}
</button>
<button
type="button"
onClick={() => setOpen(false)}
className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-primary transition-colors"
>
Cancel
</button>
</div>
</form>
);
}
Loading
Loading