From 30d351e96e5fb90e8cea504f95d828b00b93daaf Mon Sep 17 00:00:00 2001 From: dimknaf <136385722+dimknaf@users.noreply.github.com> Date: Mon, 25 May 2026 13:42:38 +0100 Subject: [PATCH 1/4] feat(frontend): read-only wiki reader + ops dashboard + agent drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a thin, zero-dependency browser frontend that talks to the existing BrainDB API via fetch — no new endpoint, no router edit, no schema migration, no Python dependency. All five files live under a new `frontend/` directory; nothing in `braindb/` is touched. Three views in one SPA: - Reader — wiki index in the left rail (canonical_name parsed from the existing preview, so no N+1 over the index), the wiki body in the centre (purpose-built renderer for the project's body grammar: meta header, headings, > **Summary:** / **Disambiguation:** callouts, dividers, GFM tables, lists, [[ref:UUID]] and [[ref:UUID|display]] citation chips including the grouped form), and a relations panel on the right grouping every relation the entity has (summarises / tagged_with / derived_from / refers_to / ...). A citation chip or relation row opens a slide-over drawer with the cited entity's content plus its own relations (drill-down). - Ops — sober dashboard surfacing live pipeline state: counts from /memory/stats, the wiki_job queue from /wiki/jobs (pending consolidate rows highlighted so duplicate-draining is visible), recent activity from /memory/log (auto-refreshes every 30s), and the always-on rules from /memory/rules. - Ask drawer — thin pass-through to POST /api/v1/agent/query (the same backend the agent skill uses). Spinner with elapsed-seconds counter; markdown rendering of the answer including ref-chip resolution. The drawer flags that the agent may save / relate when asked to, matching skill behaviour. Pure-read except where intentional: only POSTs are /memory/context (search) and /agent/query (Ask). No /memory/sql, no direct DB access. Design language: Wikipedia-serious, 2026-professional — near-monochrome palette with one restrained encyclopedic-blue accent, refined serif (Charter/Georgia) for body, clean grotesque for UI, hairline rules, no candy, light/dark via `data-theme` on . Fully keyboard navigable (/ to focus search, Cmd/Ctrl+K to open Ask, Esc to close drawers). Run locally: python -m http.server 8080 -d frontend # then open http://localhost:8080 --- frontend/README.md | 52 ++++ frontend/app.js | 667 ++++++++++++++++++++++++++++++++++++++++ frontend/index.html | 108 +++++++ frontend/style.css | 654 +++++++++++++++++++++++++++++++++++++++ frontend/wiki-render.js | 270 ++++++++++++++++ 5 files changed, 1751 insertions(+) create mode 100644 frontend/README.md create mode 100644 frontend/app.js create mode 100644 frontend/index.html create mode 100644 frontend/style.css create mode 100644 frontend/wiki-render.js diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7a5764c --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,52 @@ +# BrainDB frontend + +A thin, read-mostly browser UI for BrainDB. Vanilla HTML / CSS / JS — no build, no npm, no framework. + +## Run + +The BrainDB backend must be running first (default: `http://localhost:8000`). From the repo root: + +```bash +python -m http.server 8090 -d frontend +``` + +Then open . + +That's it. The frontend is a static page that talks to the API directly via `fetch`. CORS is already open on the backend. + +> If `8090` is in use on your machine, pick any free port: `python -m http.server -d frontend`. (Avoid `8080` on Windows with Docker Desktop installed — it's commonly held by the WSL backend.) + +## Pointing at a non-local backend + +If your API lives somewhere other than `localhost:8000`, set `window.BRAINDB_API_URL` before `app.js` loads — e.g. inject a small `` before the module tag in `index.html`. + +## What's in here + +| File | Purpose | +|---|---| +| `index.html` | Layout shell: top bar with Reader / Ops tabs, the Reader grid (rail / wiki body / relations), the Ops grid (stats / queue / log / rules), and two slide-over drawers (entity / Ask). | +| `style.css` | Design language: near-monochrome palette, refined serif for body, clean grotesque for UI, hairline rules, one restrained accent. Light + dark via `data-theme` on ``. | +| `wiki-render.js` | Small Markdown-ish renderer specialised for the BrainDB wiki body grammar — ``, headings, `> **Summary:** / **Disambiguation:**` callouts, `` dividers, GFM tables, lists, `**bold**`, `` `code` ``, and `[[ref:UUID]]` / `[[ref:UUID|display]]` citation chips (tolerant of the grouped form). | +| `app.js` | Data layer + routing + Ops auto-refresh + Ask drawer wiring. ES module; imports `wiki-render.js`. | + +## Keyboard + +- `/` — focus the search box (Reader) +- `Cmd/Ctrl+K` — open the Ask drawer +- `Esc` — close any open drawer + +## Endpoints used + +Read-only except where intentional: + +- `GET /api/v1/entities?entity_type=wiki` — wiki index +- `GET /api/v1/entities/{id}` — full entity (wiki body, fact, thought, …) +- `GET /api/v1/entities/{id}/relations` — relations of one entity +- `GET /api/v1/wiki/jobs` — Ops queue +- `GET /api/v1/memory/log` — Ops activity log +- `GET /api/v1/memory/stats` — Ops stats strip +- `GET /api/v1/memory/rules` — Ops rules list +- `POST /api/v1/memory/context` — search (the modern keyword-mediated path) +- `POST /api/v1/agent/query` — the Ask drawer + +No `/memory/sql`. No direct database access. No write outside the user-driven Ask drawer. diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..470584f --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,667 @@ +// ============================================================ +// app.js — BrainDB read-only frontend +// - Data layer: thin fetch wrappers over the existing API +// - Routing: hash-based (#/wiki/, #/ops, #/) +// - Reader, Ops, and Ask drawer wiring +// ============================================================ + +import { renderWiki, extractSections, consistencyCheck } from "./wiki-render.js"; + +const API = (window.BRAINDB_API_URL || "http://localhost:8000") + "/api/v1"; + +// ============================================================ +// Data layer +// ============================================================ +async function api(path, opts = {}) { + const r = await fetch(API + path, opts); + if (!r.ok) { + let body = ""; + try { body = await r.text(); } catch {} + throw new Error(`HTTP ${r.status} ${r.statusText} on ${path}\n${body.slice(0, 400)}`); + } + return r.json(); +} +const apiGet = (path) => api(path); +const apiPost = (path, body) => api(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), +}); + +const data = { + listWikis: () => apiGet("/entities?entity_type=wiki&limit=200"), + entity: (id) => apiGet(`/entities/${id}`), + relations: (id) => apiGet(`/entities/${id}/relations`), + search: (queries) => apiPost("/memory/context", { queries, max_depth: 1 }), + jobs: () => apiGet("/wiki/jobs?limit=200"), + log: () => apiGet("/memory/log?limit=50"), + stats: () => apiGet("/memory/stats"), + rules: () => apiGet("/memory/rules"), + agent: (query) => apiPost("/agent/query", { query }), +}; + +// ============================================================ +// Helpers +// ============================================================ +const $ = (sel, root = document) => root.querySelector(sel); +const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); + +function escapeHtml(s) { + return String(s || "") + .replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """).replace(/'/g, "'"); +} + +// Pull a clean wiki name from the preview's first lines without a full fetch. +// Prefers the `# Title` body line (always clean) over the meta header, because +// LLM emitters sometimes write multi-word canonical_name VALUES unquoted, e.g. +// `canonical_name=Value Investing language=en …`, which a naive regex +// truncates to the first word. +function previewCanonicalName(preview) { + if (!preview) return null; + const h = preview.match(/^#\s+(.+)$/m); + if (h) return h[1].trim(); + // Fallback: parse the meta header tolerantly (quoted OR unquoted multi-word). + const m = preview.match(/canonical_name=(?:"([^"]+)"|([^\s][^>]*?))(?=\s+\w+=|\s*-->|$)/); + if (m) return (m[1] || m[2] || "").trim(); + return null; +} + +function isRetired(wiki) { + // The list endpoint returns previews + the entity row; importance≈0 + a + // visible "redirect_to:" line in the preview is a strong retirement signal. + // Defensive — fall back to false. + if (wiki.importance != null && wiki.importance < 0.05) return true; + return false; +} + +// ============================================================ +// Theme +// ============================================================ +function initTheme() { + const saved = localStorage.getItem("braindb-theme"); + if (saved === "dark") document.documentElement.dataset.theme = "dark"; + $("#theme-toggle").addEventListener("click", () => { + const cur = document.documentElement.dataset.theme; + if (cur === "dark") { + delete document.documentElement.dataset.theme; + localStorage.setItem("braindb-theme", "light"); + } else { + document.documentElement.dataset.theme = "dark"; + localStorage.setItem("braindb-theme", "dark"); + } + }); +} + +// ============================================================ +// Tabs +// ============================================================ +function setTab(name) { + $$(".tab").forEach(t => t.classList.toggle("is-active", t.dataset.tab === name)); + $$(".view").forEach(v => { + const on = v.id === name; + v.classList.toggle("is-active", on); + v.hidden = !on; + }); +} + +function initTabs() { + $$(".tab").forEach(t => t.addEventListener("click", () => { + const name = t.dataset.tab; + if (name === "ops") location.hash = "#/ops"; + else location.hash = "#/"; + })); +} + +// ============================================================ +// Routing +// ============================================================ +function parseHash() { + const h = (location.hash || "#/").slice(1); + if (h.startsWith("/wiki/")) return { route: "wiki", id: h.slice(6) }; + if (h.startsWith("/ops")) return { route: "ops" }; + return { route: "reader" }; +} + +async function handleRoute() { + const r = parseHash(); + if (r.route === "ops") { + setTab("ops"); + await loadOps(); + return; + } + setTab("reader"); + if (r.route === "wiki") { + await openWiki(r.id); + } +} + +// ============================================================ +// Reader — wiki index +// ============================================================ +let wikiIndexCache = null; + +async function loadWikiIndex() { + const items = await data.listWikis(); + // items is a list of entity rows with `content` containing the preview + wikiIndexCache = items; + renderWikiIndex(items); +} + +function renderWikiIndex(items) { + const ul = $("#wiki-index"); + ul.innerHTML = ""; + const sorted = [...items].sort((a, b) => { + const an = previewCanonicalName(a.content) || a.id; + const bn = previewCanonicalName(b.content) || b.id; + return an.localeCompare(bn); + }); + for (const w of sorted) { + const li = document.createElement("li"); + const name = previewCanonicalName(w.content) || w.id.slice(0, 8); + li.dataset.id = w.id; + li.textContent = name; + if (isRetired(w)) { + const tag = document.createElement("span"); + tag.className = "retired-tag"; + tag.textContent = "retired"; + li.appendChild(tag); + } + li.addEventListener("click", () => { + location.hash = `#/wiki/${w.id}`; + }); + ul.appendChild(li); + } +} + +function markActiveInIndex(id) { + $$("#wiki-index li").forEach(li => li.classList.toggle("is-active", li.dataset.id === id)); +} + +// ============================================================ +// Reader — open one wiki +// ============================================================ +async function openWiki(id) { + markActiveInIndex(id); + const view = $("#wiki-view"); + view.innerHTML = '
Loading…
'; + + let entity, relations; + try { + [entity, relations] = await Promise.all([data.entity(id), data.relations(id)]); + } catch (e) { + view.innerHTML = `
Failed to load wiki:
${escapeHtml(e.message)}
`; + return; + } + + const body = entity.content || ""; + const rendered = renderWiki(body); + const meta = rendered.meta || {}; + + // Consistency: compare inline refs to `summarises` relations + const summarisesIds = (relations || []) + .filter(r => r.relation_type === "summarises") + .map(r => r.to_entity_id); + const cc = consistencyCheck(body, summarisesIds); + + const metaStrip = ` +
+ ${meta.canonical_name ? `${escapeHtml(meta.canonical_name)}` : ""} + ${meta.language ? `${escapeHtml(meta.language)}` : ""} + ${entity.revision != null ? `rev ${entity.revision}` : ""} + ${entity.retired_at ? `retired` : ""} + ${cc.consistent + ? `CONSISTENT ✓` + : `${cc.inline} inline / ${cc.relations} relations`} +
+ `; + + view.innerHTML = ` +
+ ${metaStrip} + ${rendered.html} +
+ `; + + // Wire citation chips: clicking opens the entity drawer + $$(".ref-chip", view).forEach(a => { + a.addEventListener("click", (ev) => { + ev.preventDefault(); + openEntityDrawer(a.dataset.ref); + }); + }); + + // Render relations panel + renderRelations(relations); +} + +// ============================================================ +// Reader — relations panel +// ============================================================ +function renderRelations(relations) { + const panel = $("#relations-panel"); + const body = $("#relations-body"); + body.innerHTML = ""; + + if (!relations || relations.length === 0) { + panel.hidden = true; + return; + } + + // Group by relation_type + const groups = {}; + for (const r of relations) { + (groups[r.relation_type] ||= []).push(r); + } + + for (const [type, rows] of Object.entries(groups).sort()) { + const g = document.createElement("div"); + g.className = "relations-group"; + const h = document.createElement("h4"); + h.textContent = `${type} (${rows.length})`; + g.appendChild(h); + for (const r of rows) { + const row = document.createElement("div"); + row.className = "relation-row"; + row.dataset.id = r.to_entity_id || r.from_entity_id; + row.innerHTML = ` +
${escapeHtml(r.to_entity_label || r.from_entity_label || r.to_entity_id || r.from_entity_id)}
+ ${r.description ? `${escapeHtml(r.description)}` : ""} + `; + row.addEventListener("click", () => openEntityDrawer(row.dataset.id)); + g.appendChild(row); + } + body.appendChild(g); + } + + panel.hidden = false; +} + +// ============================================================ +// Entity drawer (for refs + relation rows) +// ============================================================ +async function openEntityDrawer(id) { + const drawer = $("#entity-drawer"); + const title = $("#entity-drawer-title"); + const body = $("#entity-drawer-body"); + title.textContent = id.slice(0, 8) + "…"; + body.innerHTML = '
Loading…
'; + openDrawer(drawer); + + let entity, relations; + try { + [entity, relations] = await Promise.all([data.entity(id), data.relations(id).catch(() => [])]); + } catch (e) { + body.innerHTML = `
Failed to load entity:
${escapeHtml(e.message)}
`; + return; + } + + title.textContent = entity.entity_type + ? `${entity.entity_type} · ${id.slice(0, 8)}` + : id.slice(0, 8); + + const sourcePill = entity.source + ? `source: ${escapeHtml(entity.source)}` : ""; + const certPill = entity.certainty != null + ? `certainty: ${entity.certainty}` : ""; + const impPill = entity.importance != null + ? `importance: ${entity.importance}` : ""; + + const isWiki = entity.entity_type === "wiki"; + const rendered = renderWiki(entity.content || ""); + const contentHtml = isWiki + ? `
${rendered.html}
` + : `
${escapeHtml(entity.content || "")}
`; + + let relationsHtml = ""; + if (relations && relations.length) { + const groups = {}; + for (const r of relations) (groups[r.relation_type] ||= []).push(r); + const groupHtml = Object.entries(groups).sort().map(([type, rows]) => { + const items = rows.map(r => { + const target = r.to_entity_id || r.from_entity_id; + const label = r.to_entity_label || r.from_entity_label || target; + return `
${escapeHtml(label)}
`; + }).join(""); + return `

${type} (${rows.length})

${items}
`; + }).join(""); + relationsHtml = `

Relations

${groupHtml}
`; + } + + body.innerHTML = ` +
${sourcePill}${certPill}${impPill}
+
+

Content

+ ${contentHtml} +
+ ${relationsHtml} + `; + + // Drill-down: clicking a relation in the drawer swaps the drawer to that entity + $$(".relation-row", body).forEach(row => { + row.addEventListener("click", () => openEntityDrawer(row.dataset.id)); + }); + // Citation chips inside the drawer also drill in + $$(".ref-chip", body).forEach(a => { + a.addEventListener("click", (ev) => { + ev.preventDefault(); + openEntityDrawer(a.dataset.ref); + }); + }); +} + +// ============================================================ +// Drawer plumbing +// ============================================================ +function openDrawer(drawer) { + drawer.hidden = false; + $("#backdrop").hidden = false; +} +function closeDrawer(drawer) { + drawer.hidden = true; + // Hide the backdrop only if no drawer is open + if ($("#entity-drawer").hidden && $("#ask-drawer").hidden) { + $("#backdrop").hidden = true; + } +} +function initDrawers() { + $$(".drawer-close").forEach(b => { + b.addEventListener("click", () => { + const which = b.dataset.close; + closeDrawer($(`#${which}-drawer`)); + }); + }); + $("#backdrop").addEventListener("click", () => { + closeDrawer($("#entity-drawer")); + closeDrawer($("#ask-drawer")); + }); +} + +// ============================================================ +// Search (uses /memory/context) +// ============================================================ +function initSearch() { + const form = $("#search-form"); + const input = $("#search-input"); + const results = $("#search-results"); + + form.addEventListener("submit", async (ev) => { + ev.preventDefault(); + const q = input.value.trim(); + if (!q) { results.hidden = true; return; } + results.hidden = false; + results.innerHTML = `
Searching…
`; + + try { + // Multi-query: pass the raw input plus split words as narrow queries. + const queries = [q, ...q.split(/\s+/).filter(w => w.length > 2)].slice(0, 4); + const dedup = [...new Set(queries)]; + const res = await data.search(dedup); + const items = res.items || res || []; + if (items.length === 0) { + results.innerHTML = `
No matches.
`; + return; + } + results.innerHTML = ""; + for (const it of items.slice(0, 30)) { + const row = document.createElement("div"); + row.className = "result-item"; + const id = it.id || it.entity_id; + const type = it.entity_type || "?"; + const previewText = (it.content || it.summary || it.preview || "").toString().slice(0, 140); + row.innerHTML = ` + ${escapeHtml(type)} + ${escapeHtml(previewCanonicalName(it.content) || (previewText.split("\n")[0] || id.slice(0, 8)))} + ${previewText ? `${escapeHtml(previewText)}` : ""} + `; + row.addEventListener("click", () => { + if (type === "wiki") location.hash = `#/wiki/${id}`; + else openEntityDrawer(id); + }); + results.appendChild(row); + } + } catch (e) { + results.innerHTML = `
Search failed: ${escapeHtml(e.message)}
`; + } + }); + + // Submit on Enter (already default), but also live-clear when input emptied + input.addEventListener("input", () => { + if (!input.value.trim()) results.hidden = true; + }); +} + +// ============================================================ +// Ops view +// ============================================================ +let opsTimer = null; + +async function loadOps() { + // Stop any prior auto-refresh + if (opsTimer) clearInterval(opsTimer); + + await Promise.all([loadStats(), loadJobs(), loadLog(), loadRules()]); + opsTimer = setInterval(() => { + loadJobs(); + loadLog(); + }, 30_000); +} + +async function loadStats() { + try { + const s = await data.stats(); + const counts = s.entity_counts || {}; + const entries = Object.entries(counts); + const html = entries.map(([k, v]) => ` +
+
${v}
+
${escapeHtml(k)}
+
+ `).join(""); + $("#ops-stats").innerHTML = html || `
No stats.
`; + } catch (e) { + console.error("loadStats failed:", e); + $("#ops-stats").innerHTML = `
${escapeHtml(e.message)}
`; + } +} + +async function loadJobs() { + try { + const jobs = await data.jobs(); + const items = jobs.items || jobs || []; + if (items.length === 0) { + $("#ops-jobs").innerHTML = `
Queue empty.
`; + return; + } + const rows = items.slice(0, 100).map(j => { + const cls = (j.status === "pending" && j.job_type === "consolidate") + ? "highlight-consolidate" : ""; + return ` + + ${escapeHtml((j.id || "").slice(0, 8))} + ${escapeHtml(j.job_type || "")} + ${escapeHtml(j.status || "")} + ${escapeHtml((j.target_wiki_id || "").slice(0, 8))} + ${escapeHtml(j.created_at || "")} + ${j.attempts != null ? j.attempts : ""} + ${escapeHtml(j.last_error || "")} + + `; + }).join(""); + $("#ops-jobs").innerHTML = ` + + + + + + ${rows} +
idtypestatustargetcreatedtrieslast_error
+ `; + } catch (e) { + console.error("loadJobs failed:", e); + $("#ops-jobs").innerHTML = `
${escapeHtml(e.message)}
`; + } +} + +async function loadLog() { + try { + const log = await data.log(); + const items = log.items || log || []; + if (items.length === 0) { + $("#ops-log").innerHTML = `
No recent activity.
`; + return; + } + const rows = items.slice(0, 50).map(l => ` + + ${escapeHtml(l.timestamp || "")} + ${escapeHtml(l.operation || "")} + ${escapeHtml(l.entity_type || "")} + ${escapeHtml((l.entity_id || "").slice(0, 8))} + ${escapeHtml(l.context_note || "")} + + `).join(""); + $("#ops-log").innerHTML = ` + + + + + + ${rows} +
timestampoperationentity_typeentitynote
+ `; + } catch (e) { + console.error("loadLog failed:", e); + $("#ops-log").innerHTML = `
${escapeHtml(e.message)}
`; + } +} + +async function loadRules() { + try { + const rules = await data.rules(); + const items = rules.items || rules || []; + if (items.length === 0) { + $("#ops-rules").innerHTML = `
No always-on rules.
`; + return; + } + $("#ops-rules").innerHTML = items.map(r => ` +
+
${escapeHtml(r.category || "")} · priority ${r.priority != null ? r.priority : "?"}
+
${escapeHtml(r.content || "")}
+
+ `).join(""); + } catch (e) { + console.error("loadRules failed:", e); + $("#ops-rules").innerHTML = `
${escapeHtml(e.message)}
`; + } +} + +// ============================================================ +// Ask drawer +// ============================================================ +function initAsk() { + const drawer = $("#ask-drawer"); + const form = $("#ask-form"); + const input = $("#ask-input"); + const submit = $("#ask-submit"); + const status = $("#ask-status"); + const out = $("#ask-output"); + + $("#ask-toggle").addEventListener("click", () => { + openDrawer(drawer); + input.focus(); + }); + + form.addEventListener("submit", async (ev) => { + ev.preventDefault(); + const q = input.value.trim(); + if (!q) return; + submit.disabled = true; + out.innerHTML = ""; + + let elapsed = 0; + status.textContent = `Thinking… 0s`; + const t0 = Date.now(); + const ticker = setInterval(() => { + elapsed = Math.round((Date.now() - t0) / 1000); + status.textContent = `Thinking… ${elapsed}s`; + }, 500); + + try { + const res = await data.agent(q); + clearInterval(ticker); + status.textContent = `Done in ${Math.round((Date.now() - t0) / 1000)}s.`; + const answer = res.answer || JSON.stringify(res, null, 2); + const rendered = renderWiki(answer); + out.innerHTML = `
${rendered.html}
`; + // Wire any ref chips in the answer + $$(".ref-chip", out).forEach(a => { + a.addEventListener("click", (e2) => { + e2.preventDefault(); + openEntityDrawer(a.dataset.ref); + }); + }); + } catch (e) { + clearInterval(ticker); + status.textContent = "Error."; + out.innerHTML = `
Failed: ${escapeHtml(e.message)}
`; + } finally { + submit.disabled = false; + } + }); +} + +// ============================================================ +// Keyboard shortcuts +// ============================================================ +function initKeys() { + document.addEventListener("keydown", (ev) => { + const tag = (ev.target.tagName || "").toLowerCase(); + const inField = tag === "input" || tag === "textarea"; + + // Esc: close any drawer + if (ev.key === "Escape") { + closeDrawer($("#entity-drawer")); + closeDrawer($("#ask-drawer")); + return; + } + // Cmd/Ctrl+K: focus Ask + if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === "k") { + ev.preventDefault(); + openDrawer($("#ask-drawer")); + $("#ask-input").focus(); + return; + } + if (inField) return; + // / : focus search (Reader) + if (ev.key === "/") { + ev.preventDefault(); + if (location.hash !== "#/ops") { + $("#search-input").focus(); + } + } + }); +} + +// ============================================================ +// Boot +// ============================================================ +async function boot() { + initTheme(); + initTabs(); + initDrawers(); + initSearch(); + initAsk(); + initKeys(); + + // Initial data load: the wiki index is cheap and needed by Reader. + try { + await loadWikiIndex(); + } catch (e) { + $("#wiki-index").innerHTML = `
  • Index failed: ${escapeHtml(e.message)}
  • `; + } + + window.addEventListener("hashchange", handleRoute); + await handleRoute(); +} + +boot(); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e7ea98e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,108 @@ + + + + + + BrainDB + + + +
    +
    BrainDB
    + +
    + + +
    +
    + +
    + +
    + + +
    +
    Select a wiki from the left.
    +
    + + +
    + + + +
    + + + + + + + + + + + + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..45e75b5 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,654 @@ +/* ============================================================ */ +/* BrainDB — Wikipedia-serious, 2026-professional */ +/* ============================================================ */ + +:root { + --paper: #ffffff; + --paper-soft: #f8f8f7; + --ink: #1b1b1b; + --ink-soft: #4a4a4a; + --ink-muted: #8a8a8a; + --rule: #eaeaea; + --rule-strong: #d0d0d0; + --accent: #0645ad; + --accent-soft: rgba(6, 69, 173, 0.07); + --highlight: #fff4c8; + + --font-body: Charter, "Bitstream Charter", Georgia, "Times New Roman", serif; + --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, system-ui, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace; + + --read-measure: 70ch; + --rail-w: 280px; + --relations-w: 320px; + --top-h: 52px; + --transition: 120ms ease; +} + +[data-theme="dark"] { + --paper: #16161a; + --paper-soft: #1e1e22; + --ink: #e6e6e6; + --ink-soft: #b8b8b8; + --ink-muted: #707078; + --rule: #2a2a2e; + --rule-strong: #3a3a40; + --accent: #79b7ff; + --accent-soft: rgba(121, 183, 255, 0.10); + --highlight: #4a4226; +} + +* { box-sizing: border-box; } + +/* Force-hide elements with the `hidden` attribute. Otherwise class-level + `display:` rules (e.g. `.drawer { display: flex }`, `#reader { display: grid }`) + win against the UA's `[hidden] { display: none }` because they have equal + or higher specificity. */ +[hidden] { display: none !important; } + +html, body { margin: 0; padding: 0; height: 100%; } + +body { + background: var(--paper); + color: var(--ink); + font-family: var(--font-body); + font-size: 16px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: var(--accent); + text-decoration: none; +} +a:hover { text-decoration: underline; } + +button { + font-family: var(--font-ui); + font-size: 13px; + color: var(--ink); + background: transparent; + border: 1px solid var(--rule-strong); + padding: 5px 12px; + cursor: pointer; + border-radius: 2px; +} +button:hover { border-color: var(--ink-soft); } +button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + +input, textarea { + font-family: var(--font-ui); + font-size: 14px; + color: var(--ink); + background: var(--paper); + border: 1px solid var(--rule-strong); + padding: 6px 10px; + border-radius: 2px; + width: 100%; +} +input:focus, textarea:focus { + outline: none; + border-color: var(--accent); +} + +/* ============================================================ */ +/* Top bar */ +/* ============================================================ */ +.top { + height: var(--top-h); + display: flex; + align-items: center; + padding: 0 18px; + border-bottom: 1px solid var(--rule); + background: var(--paper); + position: sticky; + top: 0; + z-index: 10; +} +.brand { + font-family: var(--font-ui); + font-weight: 600; + letter-spacing: 0.02em; + font-size: 15px; + color: var(--ink); + margin-right: 24px; +} +.tabs { display: flex; gap: 4px; flex: 1; } +.tab { + border: none; + background: transparent; + padding: 6px 12px; + color: var(--ink-soft); + font-family: var(--font-ui); + font-size: 13px; + border-radius: 0; + border-bottom: 2px solid transparent; +} +.tab:hover { color: var(--ink); border-bottom-color: var(--rule); } +.tab.is-active { color: var(--ink); border-bottom-color: var(--ink); } + +.top-actions { display: flex; gap: 8px; align-items: center; } +.ask-button { + font-family: var(--font-ui); + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.theme-toggle { + width: 28px; height: 28px; + padding: 0; + font-size: 14px; + border-color: var(--rule); +} + +/* ============================================================ */ +/* View shells */ +/* ============================================================ */ +main { min-height: calc(100vh - var(--top-h)); } +.view { display: none; } +.view.is-active { display: block; } + +/* ============================================================ */ +/* Reader layout */ +/* ============================================================ */ +#reader { + display: grid; + grid-template-columns: var(--rail-w) minmax(0, 1fr) var(--relations-w); + grid-template-areas: "rail wiki relations"; + min-height: calc(100vh - var(--top-h)); +} +.rail { + grid-area: rail; + border-right: 1px solid var(--rule); + padding: 18px 18px 60px; + font-family: var(--font-ui); + font-size: 13px; + overflow-y: auto; + max-height: calc(100vh - var(--top-h)); + position: sticky; + top: var(--top-h); +} +.rail-section-label { + font-family: var(--font-ui); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.10em; + text-transform: uppercase; + color: var(--ink-muted); + margin: 18px 0 10px; + display: flex; + align-items: center; + gap: 8px; +} +.search { margin: 4px 0 14px; } +.results { + border: 1px solid var(--rule); + background: var(--paper-soft); + padding: 8px; + margin-bottom: 14px; + max-height: 280px; + overflow-y: auto; +} +.results-empty { color: var(--ink-muted); padding: 6px 4px; } +.result-item { + padding: 6px 4px; + border-bottom: 1px solid var(--rule); + cursor: pointer; +} +.result-item:last-child { border-bottom: none; } +.result-item:hover { background: var(--accent-soft); } +.result-type { + display: inline-block; + font-family: var(--font-ui); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--ink-muted); + margin-right: 6px; +} +.result-preview { + color: var(--ink-soft); + font-size: 12px; + display: block; + margin-top: 2px; +} + +.wiki-index { list-style: none; padding: 0; margin: 0; } +.wiki-index li { + padding: 4px 0; + border-bottom: 1px solid var(--rule); + cursor: pointer; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.wiki-index li:hover { color: var(--accent); } +.wiki-index li.is-active { font-weight: 600; } +.wiki-index .retired-tag { + font-family: var(--font-ui); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ink-muted); +} + +/* ============================================================ */ +/* Wiki view (the page body) */ +/* ============================================================ */ +.wiki-view { + grid-area: wiki; + padding: 36px 56px 80px; + max-width: 100%; + overflow-x: hidden; +} +.wiki-view .empty { + color: var(--ink-muted); + font-style: italic; + text-align: center; + margin-top: 80px; +} +.wiki-content { + max-width: var(--read-measure); + margin: 0 auto; +} +.wiki-meta { + font-family: var(--font-ui); + font-size: 11px; + letter-spacing: 0.06em; + color: var(--ink-muted); + text-transform: uppercase; + margin-bottom: 4px; +} +.wiki-meta .meta-piece { margin-right: 14px; } +.wiki-meta .badge { + display: inline-block; + padding: 1px 6px; + border: 1px solid var(--rule-strong); + border-radius: 2px; + margin-left: 6px; +} +.wiki-meta .badge.consistent { color: var(--ink-soft); } +.wiki-meta .badge.inconsistent { color: var(--accent); border-color: var(--accent); } +.wiki-meta .badge.retired { color: var(--ink-muted); } + +.wiki-content h1 { + font-family: var(--font-body); + font-weight: 600; + font-size: 32px; + line-height: 1.15; + margin: 6px 0 24px; + padding-bottom: 14px; + border-bottom: 1px solid var(--rule); +} +.wiki-content h2 { + font-family: var(--font-body); + font-weight: 600; + font-size: 22px; + line-height: 1.25; + margin: 32px 0 12px; + padding-bottom: 4px; + border-bottom: 1px solid var(--rule); +} +.wiki-content h3 { + font-family: var(--font-body); + font-weight: 600; + font-size: 17px; + margin: 24px 0 8px; +} + +.wiki-content p { margin: 0 0 14px; } +.wiki-content ul, .wiki-content ol { margin: 0 0 14px; padding-left: 24px; } +.wiki-content li { margin: 3px 0; } +.wiki-content code { + font-family: var(--font-mono); + font-size: 0.92em; + background: var(--paper-soft); + padding: 1px 4px; + border-radius: 2px; +} +.wiki-content blockquote.callout { + margin: 0 0 14px; + padding: 10px 16px; + background: var(--paper-soft); + border-left: 3px solid var(--rule-strong); + font-style: italic; +} +.wiki-content blockquote.callout strong { font-style: normal; } +.wiki-content blockquote.callout.summary { border-left-color: var(--accent); } + +.wiki-content table { + border-collapse: collapse; + margin: 0 0 18px; + font-size: 14px; + width: 100%; +} +.wiki-content th, .wiki-content td { + border: 1px solid var(--rule); + padding: 6px 10px; + text-align: left; + vertical-align: top; +} +.wiki-content th { + background: var(--paper-soft); + font-family: var(--font-ui); + font-weight: 600; + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.wiki-content hr.section-divider { + border: none; + border-top: 1px solid var(--rule); + margin: 22px 0 0; +} + +/* Citation chip — superscript-style reference */ +.ref-chip { + display: inline-block; + font-family: var(--font-ui); + font-size: 0.72em; + vertical-align: super; + line-height: 1; + color: var(--accent); + border: 1px solid var(--accent); + border-radius: 2px; + padding: 0 4px; + margin: 0 1px; + cursor: pointer; + text-decoration: none; + background: var(--accent-soft); +} +.ref-chip:hover { background: var(--accent); color: var(--paper); } + +/* References section at the bottom */ +.wiki-content .refs-list { + font-size: 14px; + margin-top: 24px; + padding-top: 14px; + border-top: 1px solid var(--rule); +} + +/* ============================================================ */ +/* Relations panel */ +/* ============================================================ */ +.relations { + grid-area: relations; + border-left: 1px solid var(--rule); + padding: 18px 18px 60px; + font-family: var(--font-ui); + font-size: 12px; + overflow-y: auto; + max-height: calc(100vh - var(--top-h)); + position: sticky; + top: var(--top-h); +} +.relations-group { margin-bottom: 18px; } +.relations-group h4 { + margin: 0 0 6px; + font-family: var(--font-ui); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-soft); + font-weight: 600; +} +.relation-row { + padding: 5px 0; + border-bottom: 1px solid var(--rule); + cursor: pointer; +} +.relation-row:hover { color: var(--accent); } +.relation-row .preview { + color: var(--ink-muted); + font-size: 11px; + display: block; + margin-top: 1px; + line-height: 1.45; +} + +/* ============================================================ */ +/* Drawers (slide-over from right) */ +/* ============================================================ */ +.drawer { + position: fixed; + top: 0; + right: 0; + height: 100vh; + width: 480px; + max-width: 90vw; + background: var(--paper); + border-left: 1px solid var(--rule-strong); + z-index: 50; + display: flex; + flex-direction: column; + box-shadow: -2px 0 0 var(--rule); +} +.drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 18px; + border-bottom: 1px solid var(--rule); + height: var(--top-h); +} +.drawer-title { + font-family: var(--font-ui); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--ink); +} +.drawer-close { + border: none; + font-size: 22px; + line-height: 1; + padding: 0 6px; + color: var(--ink-soft); +} +.drawer-close:hover { color: var(--ink); border: none; } +.drawer-body { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + font-family: var(--font-body); + font-size: 15px; + line-height: 1.55; +} +.drawer-body .entity-section { margin-bottom: 20px; } +.drawer-body .entity-section h4 { + font-family: var(--font-ui); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-soft); + margin: 0 0 6px; +} +.drawer-body .entity-meta { + font-family: var(--font-ui); + font-size: 12px; + color: var(--ink-muted); + margin-bottom: 16px; +} +.drawer-body .entity-meta .pill { + display: inline-block; + background: var(--paper-soft); + padding: 1px 7px; + border-radius: 2px; + margin-right: 6px; +} +.drawer-body pre { + font-family: var(--font-mono); + font-size: 12px; + background: var(--paper-soft); + padding: 8px 10px; + white-space: pre-wrap; + word-break: break-word; +} + +.backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.18); + z-index: 40; +} +[data-theme="dark"] .backdrop { background: rgba(0, 0, 0, 0.40); } + +/* ============================================================ */ +/* Ask drawer specifics */ +/* ============================================================ */ +.ask-note { + font-family: var(--font-ui); + font-size: 12px; + color: var(--ink-muted); + margin: 0 0 12px; + line-height: 1.45; +} +.ask-form textarea { + font-family: var(--font-body); + font-size: 15px; + resize: vertical; + min-height: 80px; +} +.ask-actions { + display: flex; + align-items: center; + gap: 12px; + margin-top: 10px; +} +.ask-status { + font-family: var(--font-ui); + font-size: 12px; + color: var(--ink-muted); +} +.ask-output { + margin-top: 20px; + font-family: var(--font-body); + font-size: 15px; + line-height: 1.6; + border-top: 1px solid var(--rule); + padding-top: 16px; +} +.ask-output:empty { display: none; } + +/* ============================================================ */ +/* Ops view */ +/* ============================================================ */ +#ops { + padding: 36px 56px 80px; + max-width: 1280px; + margin: 0 auto; +} +.ops-grid { display: grid; gap: 32px; } +.ops-block { font-family: var(--font-ui); font-size: 13px; } + +.stats-strip { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 14px; +} +.stats-strip .stat { + border: 1px solid var(--rule); + padding: 10px 12px; + background: var(--paper-soft); +} +.stats-strip .stat .v { + font-family: var(--font-body); + font-size: 22px; + font-weight: 600; +} +.stats-strip .stat .k { + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-muted); + margin-top: 2px; +} + +.table-wrap { overflow-x: auto; } +table.ops-table { + width: 100%; + border-collapse: collapse; + font-family: var(--font-mono); + font-size: 12px; +} +table.ops-table th, table.ops-table td { + border-bottom: 1px solid var(--rule); + padding: 5px 10px; + text-align: left; + vertical-align: top; + white-space: nowrap; +} +table.ops-table th { + font-family: var(--font-ui); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-soft); + font-weight: 600; + background: var(--paper-soft); + position: sticky; + top: 0; +} +table.ops-table tr.highlight-consolidate { background: var(--highlight); } +table.ops-table td.id-cell { + color: var(--ink-muted); + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; +} +table.ops-table td.context { + font-family: var(--font-body); + white-space: normal; + max-width: 420px; +} + +.rules-list { display: grid; gap: 6px; } +.rule-row { + padding: 6px 10px; + border: 1px solid var(--rule); + background: var(--paper-soft); + font-family: var(--font-body); + font-size: 14px; +} +.rule-row .rule-meta { + font-family: var(--font-ui); + font-size: 11px; + letter-spacing: 0.06em; + color: var(--ink-muted); + margin-bottom: 2px; + text-transform: uppercase; +} + +.live-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + opacity: 0.65; + margin-left: 4px; +} + +/* ============================================================ */ +/* Responsive degradation */ +/* ============================================================ */ +@media (max-width: 1100px) { + #reader { + grid-template-columns: var(--rail-w) minmax(0, 1fr); + grid-template-areas: "rail wiki"; + } + .relations { display: none; } +} +@media (max-width: 720px) { + #reader { + grid-template-columns: 1fr; + grid-template-areas: "wiki"; + } + .rail { display: none; } + .wiki-view { padding: 24px 18px 60px; } + #ops { padding: 24px 18px 60px; } + .drawer { width: 100%; max-width: 100%; } +} diff --git a/frontend/wiki-render.js b/frontend/wiki-render.js new file mode 100644 index 0000000..9a66ff3 --- /dev/null +++ b/frontend/wiki-render.js @@ -0,0 +1,270 @@ +// ============================================================ +// wiki-render.js — minimal Markdown renderer for BrainDB wiki +// body grammar. No dependencies. +// +// Handles: +// +// # Heading, ## Heading, ### Heading +// > **Summary:** … and > **Disambiguation:** … callouts +// dividers +// GFM tables (| col | col |) +// - / * / 1. lists +// **bold**, `code`, [text](url) +// [[ref:UUID|optional display]] and [[ref:UUID]] chips, +// tolerantly handling the grouped form [[ref:a], [ref:b]] +// ============================================================ + +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; + +function escapeHtml(s) { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +// Inline pass: refs → chips, **bold**, `code`, [text](url) +function renderInline(text) { + // Protect anything dangerous, then progressively replace tokens. + let out = escapeHtml(text); + + // [[ref:UUID|display]] and [[ref:UUID]] + // Tolerant: also catches [ref:UUID] inside a grouped [[ref:a], [ref:b]] + out = out.replace(/\[\[ref:([0-9a-f-]+)(?:\|([^\]]+))?\]\]/gi, + (_, id, disp) => refChip(id, disp)); + out = out.replace(/\[ref:([0-9a-f-]+)(?:\|([^\]]+))?\]/gi, + (_, id, disp) => refChip(id, disp)); + + // [text](url) + out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, + (_, label, url) => `${label}`); + + // `code` + out = out.replace(/`([^`]+)`/g, (_, c) => `${c}`); + + // **bold** + out = out.replace(/\*\*([^*]+)\*\*/g, (_, b) => `${b}`); + + return out; +} + +function refChip(id, displayMaybe) { + if (!UUID_RE.test(id)) return `${escapeHtml(id)}`; + const display = displayMaybe ? escapeHtml(displayMaybe) : id.slice(0, 6); + return `${display}`; +} + +// Parse from the body's first line. +// Tolerant: keys are unquoted; values may contain semicolons; the key list is +// space-separated; we don't enforce a fixed schema. +function parseMeta(line) { + const m = line.match(//); + if (!m) return null; + const meta = {}; + const re = /(\w+)=("[^"]*"|[^\s]+)/g; + let mm; + while ((mm = re.exec(m[1])) !== null) { + meta[mm[1]] = mm[2].replace(/^"|"$/g, ""); + } + return meta; +} + +// Top-level: turn the full wiki body markdown into HTML. +// Returns { html, meta, refs:[uuid...] } +export function renderWiki(body) { + if (!body) return { html: "", meta: null, refs: [] }; + + const lines = body.split(/\r?\n/); + let meta = null; + let i = 0; + + // 1. meta header (first non-empty line that matches) + while (i < lines.length && lines[i].trim() === "") i++; + if (i < lines.length) { + const candidate = parseMeta(lines[i]); + if (candidate) { + meta = candidate; + i++; + } + } + + const out = []; + const refs = new Set(); + + while (i < lines.length) { + const line = lines[i]; + + // Section divider marker + const sec = line.match(/^/); + if (sec) { + out.push(`
    `); + i++; + continue; + } + + // Other HTML comments — skip + if (/^$/.test(line.trim())) { i++; continue; } + + // Blank line + if (line.trim() === "") { i++; continue; } + + // # Title (h1) + if (/^#\s+/.test(line)) { + const text = line.replace(/^#\s+/, ""); + out.push(`

    ${renderInline(text)}

    `); + i++; + continue; + } + // ## Heading (h2) + if (/^##\s+/.test(line)) { + const text = line.replace(/^##\s+/, ""); + out.push(`

    ${renderInline(text)}

    `); + i++; + continue; + } + // ### Heading (h3) + if (/^###\s+/.test(line)) { + const text = line.replace(/^###\s+/, ""); + out.push(`

    ${renderInline(text)}

    `); + i++; + continue; + } + + // Callout: > **Summary:** ... (multi-line — collect contiguous > lines) + if (/^>\s*/.test(line)) { + const block = []; + while (i < lines.length && /^>\s*/.test(lines[i])) { + block.push(lines[i].replace(/^>\s?/, "")); + i++; + } + const joined = block.join(" "); + const kindMatch = joined.match(/^\*\*(Summary|Disambiguation)[:\s]\*\*\s*(.+)/i); + let cls = "callout"; + if (kindMatch) { + cls += " " + kindMatch[1].toLowerCase(); + // Escape the label, render-inline the prose, concatenate outside — + // never put pre-built HTML inside a string that goes through + // renderInline (escapeHtml would mangle the tags). + const labelText = kindMatch[1]; + const rest = kindMatch[2]; + out.push(`
    ${escapeHtml(labelText)}: ${renderInline(rest)}
    `); + } else { + out.push(`
    ${renderInline(joined)}
    `); + } + continue; + } + + // Table (GFM): a line starting with `|`, followed by a separator line + if (/^\|/.test(line) && i + 1 < lines.length && /^\|\s*[-:|\s]+$/.test(lines[i + 1])) { + const rows = []; + while (i < lines.length && /^\|/.test(lines[i])) { + rows.push(lines[i]); + i++; + } + out.push(renderTable(rows)); + continue; + } + + // Bullet list (- or *) + if (/^[-*]\s+/.test(line)) { + const items = []; + while (i < lines.length && /^[-*]\s+/.test(lines[i])) { + items.push(lines[i].replace(/^[-*]\s+/, "")); + i++; + } + const li = items.map(it => `
  • ${renderInline(it)}
  • `).join(""); + out.push(`
      ${li}
    `); + continue; + } + + // Numbered list + if (/^\d+\.\s+/.test(line)) { + const items = []; + while (i < lines.length && /^\d+\.\s+/.test(lines[i])) { + items.push(lines[i].replace(/^\d+\.\s+/, "")); + i++; + } + const li = items.map(it => `
  • ${renderInline(it)}
  • `).join(""); + out.push(`
      ${li}
    `); + continue; + } + + // Paragraph: gather consecutive non-empty lines + const para = []; + while (i < lines.length && lines[i].trim() !== "" + && !/^[#>|-]/.test(lines[i]) + && !/^\d+\.\s+/.test(lines[i]) + && !/^/); + if (sec) { + // Look ahead for the next heading or callout-bold to use as a title + let title = sec[1].replace(/-/g, " "); + for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) { + const h = lines[j].match(/^#{2,3}\s+(.+)/); + if (h) { title = h[1]; break; } + } + sections.push({ slug: sec[1], title }); + } + } + return sections; +} + +// Compare inline [[ref:UUID]] chips to the wiki's `summarises` relations. +// Returns { inline: N, relations: M, consistent: bool, only_inline: [...], only_relations: [...] } +// Mirrors the spirit of export_wikis._consistency. +export function consistencyCheck(body, summarisesIds) { + const inline = new Set(); + if (body) { + const re = /\[\[?ref:([0-9a-f-]+)/gi; + let m; + while ((m = re.exec(body)) !== null) inline.add(m[1]); + } + const rel = new Set(summarisesIds || []); + const onlyInline = [...inline].filter(x => !rel.has(x)); + const onlyRel = [...rel].filter(x => !inline.has(x)); + return { + inline: inline.size, + relations: rel.size, + consistent: onlyInline.length === 0 && onlyRel.length === 0, + only_inline: onlyInline, + only_relations: onlyRel, + }; +} From 16cdac1fe641480ebb2e834475784f3a36cd47f4 Mon Sep 17 00:00:00 2001 From: dimknaf <136385722+dimknaf@users.noreply.github.com> Date: Mon, 25 May 2026 20:46:10 +0100 Subject: [PATCH 2/4] feat(frontend): Graph tab (vis-network) + Reader elastic search + type-pill results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks in the working vis-network-based graph view and the Reader-side search refactor as a baseline on a new branch (feat/wiki-frontend-2), so subsequent improvements can be reverted easily. Frontend changes (no backend touched): - Graph tab: replaces the earlier Cytoscape attempt with vis-network. Wiki = blue rounded-rectangle (label inside), fact = grey ellipse, thought = dashed ellipse, keyword = amber diamond, source/datasource = teal database glyph, rule = purple box. Edges colour-coded by relation_type, labels hidden by default and revealed on hover or when a node is selected. IntersectionObserver-driven mount centres the graph correctly even when the Graph tab starts hidden. 300-node soft cap with toast on overflow. - Reader rail search: refactored to runReaderSearch() called directly from both submit and a 180 ms input-debounce (no requestSubmit indirection that silently failed). Each result row shows a coloured type-pill (WIKI / FACT / KEYWORD / etc.) and a clean preview snippet (strips wiki:meta comments + leading # Title before display). A breakdown bar at the top shows the type mix (e.g. "wiki 8 · fact 4"). - Round-2 fixes carried over: [hidden] !important rule, previewCanonical- Name fallback to # Title, loadStats reads entity_counts, console.error on every Ops catch, callout no longer escapes its own . --- frontend/README.md | 23 ++- frontend/app.js | 217 ++++++++++++++++++++--- frontend/graph.js | 416 ++++++++++++++++++++++++++++++++++++++++++++ frontend/index.html | 33 ++++ frontend/style.css | 189 ++++++++++++++++++++ 5 files changed, 847 insertions(+), 31 deletions(-) create mode 100644 frontend/graph.js diff --git a/frontend/README.md b/frontend/README.md index 7a5764c..8ef436a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -24,16 +24,26 @@ If your API lives somewhere other than `localhost:8000`, set `window.BRAINDB_API | File | Purpose | |---|---| -| `index.html` | Layout shell: top bar with Reader / Ops tabs, the Reader grid (rail / wiki body / relations), the Ops grid (stats / queue / log / rules), and two slide-over drawers (entity / Ask). | -| `style.css` | Design language: near-monochrome palette, refined serif for body, clean grotesque for UI, hairline rules, one restrained accent. Light + dark via `data-theme` on ``. | +| `index.html` | Layout shell: top bar with Reader / Graph / Ops tabs, the Reader grid (rail / wiki body / relations), the Graph view (Cytoscape canvas + toolbar + legend), the Ops grid (stats / queue / log / rules), and two slide-over drawers (entity / Ask). Loads Cytoscape + fCoSE from a CDN. | +| `style.css` | Design language: near-monochrome palette, refined serif for body, clean grotesque for UI, hairline rules, one restrained accent. Light + dark via `data-theme` on ``. Graph view tokens included. | | `wiki-render.js` | Small Markdown-ish renderer specialised for the BrainDB wiki body grammar — ``, headings, `> **Summary:** / **Disambiguation:**` callouts, `` dividers, GFM tables, lists, `**bold**`, `` `code` ``, and `[[ref:UUID]]` / `[[ref:UUID|display]]` citation chips (tolerant of the grouped form). | -| `app.js` | Data layer + routing + Ops auto-refresh + Ask drawer wiring. ES module; imports `wiki-render.js`. | +| `graph.js` | Cytoscape.js integration: per-entity-type shapes/colours, edge-label-on-hover, click → entity drawer, double-click → expand 1-hop neighbourhood, 300-node soft cap. | +| `app.js` | Data layer + routing + Ops auto-refresh + Ask drawer wiring + Graph tab wiring. ES module; imports `wiki-render.js` and `graph.js`. | ## Keyboard - `/` — focus the search box (Reader) - `Cmd/Ctrl+K` — open the Ask drawer - `Esc` — close any open drawer +- `F` — fit graph to viewport (Graph tab) + +## Graph tab — quick tour + +- Open a wiki in **Reader**, then click the **Graph** tab → the graph seeds with that wiki and its direct neighbours (keywords, facts, sources). +- Cold-start the Graph tab → empty canvas with a search box; pick a result to seed. +- **Click** any node → opens the entity drawer (same one Reader uses). +- **Double-click** a node → expands its 1-hop neighbourhood. +- Scroll to zoom, drag empty space to pan. Soft-capped at 300 nodes. ## Endpoints used @@ -50,3 +60,10 @@ Read-only except where intentional: - `POST /api/v1/agent/query` — the Ask drawer No `/memory/sql`. No direct database access. No write outside the user-driven Ask drawer. + +## External dependencies (CDN, optional) + +- [Cytoscape.js](https://js.cytoscape.org/) 3.30.4 — graph rendering for the Graph tab. +- [cytoscape-fcose](https://github.com/iVis-at-Bilkent/cytoscape.js-fcose) 2.2.0 — force-directed layout for the Graph tab. + +Both are loaded from `unpkg.com` in `index.html` with pinned versions. If the CDN is unreachable, the Graph tab shows an error message and the rest of the app (Reader / Ops / Ask) continues to work. diff --git a/frontend/app.js b/frontend/app.js index 470584f..33aea4f 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -6,6 +6,7 @@ // ============================================================ import { renderWiki, extractSections, consistencyCheck } from "./wiki-render.js"; +import * as graph from "./graph.js"; const API = (window.BRAINDB_API_URL || "http://localhost:8000") + "/api/v1"; @@ -52,6 +53,25 @@ function escapeHtml(s) { .replace(/"/g, """).replace(/'/g, "'"); } +// Build a clean human-readable snippet from an entity's content. +// Strips the leading `` comment, the `# Title` line, and +// blockquote markers; for wikis it prefers the `> **Summary:** …` callout text. +function entitySnippet(content, isWiki = false) { + if (!content) return ""; + let body = String(content); + // Strip a single leading HTML comment (the wiki:meta header). + body = body.replace(/^\s*\s*/, ""); + if (isWiki) { + const sum = body.match(/^>\s*\*\*Summary[:\s]\*\*\s*([\s\S]+?)(?:\n>|\n\n|$)/im); + if (sum) return sum[1].replace(/\s+/g, " ").trim().slice(0, 160); + } + const cleaned = body + .split(/\r?\n/) + .map(l => l.replace(/^#+\s*/, "").replace(/^>\s*/, "").trim()) + .filter(l => l.length > 0 && !/^|$)/); + if (m) return truncate((m[1] || m[2] || "").trim(), 24); + return idShort; + } + if (entity.entity_type === "keyword") { + const k = (entity.content || "").trim(); + return k ? truncate(k, 20) : idShort; + } + const c = (entity.title || entity.content || "").toString().trim(); + if (c) return truncate(c.split(/\r?\n/)[0], 24); + return idShort; +} + +function truncate(s, n) { + if (!s) return ""; + return s.length > n ? s.slice(0, n - 1) + "…" : s; +} + +// ------------------------------------------------------------ +// Public API +// ------------------------------------------------------------ +export function isInitialised() { + return network !== null; +} + +export function ensureMounted(container, onNodeClick) { + if (network) return network; + + if (typeof window.vis === "undefined" || !window.vis.Network) { + container.innerHTML = `
    Graph library failed to load (vis-network unavailable). Check the CDN script in index.html.
    `; + return null; + } + + onClickCb = onNodeClick; + + nodesDS = new window.vis.DataSet([]); + edgesDS = new window.vis.DataSet([]); + + const options = { + autoResize: true, + nodes: { + // sensible default; per-node overrides supplied in nodeConfig() + borderWidth: 1, + borderWidthSelected: 3, + scaling: { label: { enabled: false } }, + }, + edges: { + color: { inherit: false }, + smooth: { enabled: true, type: "dynamic" }, + hoverWidth: 0, + selectionWidth: 0, + }, + physics: { + enabled: true, + barnesHut: { + gravitationalConstant: -8000, + centralGravity: 0.05, + springLength: 130, + springConstant: 0.04, + damping: 0.6, + avoidOverlap: 0.5, + }, + stabilization: { + enabled: true, + iterations: 250, + updateInterval: 25, + fit: true, // <-- vis-network's own auto-fit after settle + }, + }, + interaction: { + hover: true, + hoverConnectedEdges: true, + zoomView: true, + dragView: true, + tooltipDelay: 250, + multiselect: false, + }, + layout: { + improvedLayout: true, + }, + }; + + network = new window.vis.Network(container, { nodes: nodesDS, edges: edgesDS }, options); + + // Click → drawer + network.on("click", (params) => { + if (params.nodes && params.nodes[0] && onClickCb) onClickCb(params.nodes[0]); + }); + + // Double-click → expand neighbourhood + network.on("doubleClick", async (params) => { + if (params.nodes && params.nodes[0]) { + await expandNode(params.nodes[0]); + } + }); + + // Hover an edge → reveal its label + network.on("hoverEdge", (params) => { + edgesDS.update({ id: params.edge, font: { size: 10, color: colours().inkSoft, strokeWidth: 3, strokeColor: colours().paper } }); + }); + network.on("blurEdge", (params) => { + edgesDS.update({ id: params.edge, font: { size: 0 } }); + }); + + // Selecting a node → reveal incident edges' labels + network.on("selectNode", (params) => { + const nid = params.nodes[0]; + if (!nid) return; + const incident = network.getConnectedEdges(nid); + const updates = incident.map(eid => ({ id: eid, font: { size: 10, color: colours().inkSoft, strokeWidth: 3, strokeColor: colours().paper } })); + edgesDS.update(updates); + }); + network.on("deselectNode", () => { + const updates = edgesDS.getIds().map(eid => ({ id: eid, font: { size: 0 } })); + edgesDS.update(updates); + }); + + // Once the layout settles, fit explicitly (belt-and-braces; physics.stabilization.fit=true also does it). + network.on("stabilized", () => { + network.fit({ animation: { duration: 250, easingFunction: "easeInOutQuad" } }); + }); + + // Hidden-container handling: if the canvas wasn't visible at mount, fit + // again once it appears. IntersectionObserver fires when the container + // gets non-zero dimensions. + if (typeof IntersectionObserver !== "undefined") { + const io = new IntersectionObserver((entries) => { + for (const e of entries) { + if (e.isIntersecting && e.target.clientWidth > 10) { + network.setSize(e.target.clientWidth + "px", e.target.clientHeight + "px"); + network.redraw(); + network.fit({ animation: false }); + } + } + }); + io.observe(container); + } + + // Window resize → refit + window.addEventListener("resize", () => { + if (network) network.redraw(); + }); + + return network; +} + +export async function seed(entityId, onStatus) { + if (!network) return; + onStatus?.("Loading…"); + try { + const entity = await apiGet(`/entities/${entityId}`); + addOrUpdateNode(entity); + await expandNode(entityId, /*silent=*/true); + onStatus?.(""); + } catch (e) { + console.error("graph.seed failed:", e); + onStatus?.(`Seed failed: ${e.message}`); + } +} + +export async function searchSeeds(query) { + if (!query.trim()) return []; + const res = await apiPost("/memory/context", { queries: [query], max_depth: 1, max_results: 12 }); + return res.items || []; +} + +export function clear() { + if (!network) return; + nodesDS.clear(); + edgesDS.clear(); +} + +export function fit() { + if (network) network.fit({ animation: { duration: 250 } }); +} + +export function reset() { + clear(); +} + +export function toggleKeywords(hide) { + if (!nodesDS) return; + const updates = nodesDS.get({ + filter: n => n.entity_type === "keyword", + }).map(n => ({ id: n.id, hidden: !!hide })); + nodesDS.update(updates); +} + +export function refreshStyle() { + if (!nodesDS) return; + // Re-apply colours from CSS variables for every node + edge (theme change). + const nodeUpdates = nodesDS.get().map(n => { + // Reconstitute config from stored entity_type + label + const cfg = nodeConfig({ id: n.id, entity_type: n.entity_type, content: n.label, title: n.label }); + return { id: n.id, color: cfg.color, font: cfg.font, shapeProperties: cfg.shapeProperties }; + }); + nodesDS.update(nodeUpdates); +} + +// ------------------------------------------------------------ +// Internals +// ------------------------------------------------------------ +function addOrUpdateNode(entity) { + if (!nodesDS) return null; + const existing = nodesDS.get(entity.id); + const cfg = nodeConfig(entity); + if (existing) { + nodesDS.update(cfg); + return cfg; + } + if (nodesDS.length >= HARD_CAP) return null; + nodesDS.add(cfg); + return cfg; +} + +function addOrUpdateEdge(rel) { + if (!edgesDS) return null; + const cfg = edgeConfig(rel); + if (edgesDS.get(cfg.id)) return cfg; + // Both endpoints must already be in the graph for the edge to render. + if (!nodesDS.get(rel.from_entity_id) || !nodesDS.get(rel.to_entity_id)) return null; + edgesDS.add(cfg); + return cfg; +} + +async function expandNode(entityId, silent = false) { + if (!network) return; + if (inFlight.has(entityId)) return; + inFlight.add(entityId); + try { + const relations = await apiGet(`/entities/${entityId}/relations`); + const ids = new Set(); + for (const r of relations) { + ids.add(r.from_entity_id); + ids.add(r.to_entity_id); + } + ids.delete(entityId); + const missing = [...ids].filter(id => !nodesDS.get(id)); + if (nodesDS.length + missing.length > HARD_CAP) { + if (!silent) flashToast(`Graph capped at ${HARD_CAP} nodes; prune before expanding more.`); + } else { + const fetched = await Promise.all(missing.map(async id => { + try { return await apiGet(`/entities/${id}`); } catch { return null; } + })); + for (const e of fetched) if (e) addOrUpdateNode(e); + } + for (const r of relations) addOrUpdateEdge(r); + } catch (e) { + console.error(`expandNode(${entityId}) failed:`, e); + if (!silent) flashToast(`Expand failed: ${e.message}`); + } finally { + inFlight.delete(entityId); + } +} + +// Small toast for non-blocking status (cap message etc.) +let toastEl = null; +function flashToast(msg) { + if (!toastEl) { + toastEl = document.createElement("div"); + toastEl.className = "graph-toast"; + document.body.appendChild(toastEl); + } + toastEl.textContent = msg; + toastEl.classList.add("show"); + clearTimeout(toastEl._t); + toastEl._t = setTimeout(() => toastEl.classList.remove("show"), 3200); +} diff --git a/frontend/index.html b/frontend/index.html index e7ea98e..d999f2a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,12 +5,15 @@ BrainDB + +
    BrainDB
    @@ -41,6 +44,36 @@ + + +