CiteGeist/examples/literature-explorer/index.html

1414 lines
49 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CiteGeist Literature Explorer</title>
<style>
:root {
--bg: #f2efe7;
--paper: rgba(255, 252, 247, 0.92);
--ink: #1f1c18;
--muted: #6d655b;
--line: rgba(60, 46, 28, 0.16);
--accent: #8d3f2d;
--accent-2: #2f5f5a;
--accent-3: #9f7a18;
--danger: #8b2720;
--shadow: 0 18px 40px rgba(38, 27, 15, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--ink);
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(219, 184, 132, 0.20), transparent 28%),
radial-gradient(circle at bottom right, rgba(96, 130, 123, 0.16), transparent 24%),
linear-gradient(180deg, #f8f3eb 0%, #efe9de 100%);
}
.shell {
width: min(1420px, calc(100vw - 2rem));
margin: 1rem auto 2rem;
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 1rem;
}
.panel {
background: var(--paper);
border: 1px solid var(--line);
border-radius: 22px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.sidebar {
padding: 1.1rem;
position: sticky;
top: 1rem;
align-self: start;
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
.content {
padding: 1rem;
display: grid;
gap: 1rem;
}
h1, h2, h3 {
margin: 0;
font-family: "IBM Plex Serif", "Georgia", serif;
line-height: 1.05;
font-weight: 600;
letter-spacing: -0.02em;
}
h1 {
font-size: 2rem;
margin-bottom: 0.4rem;
}
h2 {
font-size: 1.15rem;
margin-bottom: 0.75rem;
}
p {
margin: 0;
color: var(--muted);
line-height: 1.45;
}
.lede {
margin-bottom: 1rem;
font-size: 0.98rem;
}
.stack {
display: grid;
gap: 0.75rem;
}
.card {
padding: 1rem;
}
.row {
display: grid;
gap: 0.65rem;
grid-template-columns: 1fr 1fr;
}
.row-3 {
display: grid;
gap: 0.65rem;
grid-template-columns: 1fr 1fr 1fr;
}
label {
display: grid;
gap: 0.35rem;
font-size: 0.88rem;
color: var(--muted);
}
input, textarea, select, button {
font: inherit;
}
input, textarea, select {
width: 100%;
border: 1px solid rgba(73, 57, 35, 0.18);
background: rgba(255, 255, 255, 0.9);
color: var(--ink);
border-radius: 14px;
padding: 0.75rem 0.85rem;
outline: none;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
input:focus, textarea:focus, select:focus {
border-color: rgba(141, 63, 45, 0.6);
box-shadow: 0 0 0 3px rgba(141, 63, 45, 0.12);
}
textarea {
min-height: 108px;
resize: vertical;
}
button {
border: 0;
border-radius: 999px;
padding: 0.72rem 1rem;
cursor: pointer;
font-weight: 600;
transition: transform 120ms ease, opacity 120ms ease, box-shadow 120ms ease;
}
button:hover { transform: translateY(-1px); }
button:disabled { opacity: 0.55; cursor: wait; transform: none; }
.primary {
background: var(--accent);
color: #fffaf5;
box-shadow: 0 10px 20px rgba(141, 63, 45, 0.18);
}
.secondary {
background: var(--accent-2);
color: #f7fbfb;
}
.tertiary {
background: #efe5cf;
color: #5d4716;
}
.full {
width: 100%;
}
.toolbar {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 0.15rem;
}
.status {
min-height: 1.5rem;
font-size: 0.92rem;
color: var(--muted);
margin-top: 0.6rem;
}
.status.error { color: var(--danger); }
.status.ok { color: var(--accent-2); }
.status.note { color: #5d4716; }
.meta-grid {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: 0.85rem;
}
.meta-box {
padding: 0.75rem 0.85rem;
border-radius: 16px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(73, 57, 35, 0.1);
}
.meta-box strong {
display: block;
font-size: 1.15rem;
margin-bottom: 0.15rem;
}
.section-grid {
display: grid;
gap: 1rem;
grid-template-columns: 1.15fr 0.85fr;
}
.list {
display: grid;
gap: 0.65rem;
}
.list-item {
border-radius: 18px;
border: 1px solid rgba(73, 57, 35, 0.11);
background: rgba(255, 255, 255, 0.74);
padding: 0.85rem 0.95rem;
}
.list-item h3 {
font-size: 1rem;
margin-bottom: 0.22rem;
}
.list-item p {
font-size: 0.92rem;
}
.pill-row {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
margin-top: 0.55rem;
}
.pill {
font-size: 0.76rem;
border-radius: 999px;
padding: 0.26rem 0.54rem;
background: #f2ead9;
color: #5f5548;
border: 1px solid rgba(93, 75, 50, 0.12);
}
.topic-link, .entry-link {
color: inherit;
text-decoration: none;
border-bottom: 1px solid rgba(141, 63, 45, 0.18);
}
.topic-link:hover, .entry-link:hover {
color: var(--accent);
border-bottom-color: rgba(141, 63, 45, 0.5);
}
.code-block {
padding: 0.9rem 1rem;
border-radius: 18px;
background: #1d1a17;
color: #efe7db;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.84rem;
line-height: 1.42;
}
.split {
display: grid;
gap: 1rem;
grid-template-columns: 1fr 1fr;
}
.empty {
padding: 1.2rem;
border-radius: 18px;
border: 1px dashed rgba(96, 84, 66, 0.32);
color: var(--muted);
background: rgba(255, 255, 255, 0.42);
}
.graph-shell {
display: grid;
gap: 0.9rem;
}
.graph-meta {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.graph-canvas {
width: 100%;
min-height: 360px;
border-radius: 20px;
border: 1px solid rgba(73, 57, 35, 0.11);
background:
radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 0.92), rgba(248, 240, 227, 0.88));
overflow: hidden;
}
.graph-svg {
width: 100%;
height: 360px;
display: block;
}
.graph-edge {
stroke: rgba(73, 57, 35, 0.28);
stroke-width: 1.4;
}
.graph-node {
cursor: pointer;
transition: transform 120ms ease;
}
.graph-node circle {
stroke: rgba(31, 28, 24, 0.22);
stroke-width: 1.2;
}
.graph-node:hover text {
fill: var(--accent);
}
.graph-label {
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
font-size: 11px;
fill: #352d25;
}
.graph-legend {
display: flex;
gap: 0.8rem;
flex-wrap: wrap;
color: var(--muted);
font-size: 0.86rem;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.legend-swatch {
width: 0.8rem;
height: 0.8rem;
border-radius: 999px;
border: 1px solid rgba(31, 28, 24, 0.16);
}
.graph-json details {
border-radius: 16px;
background: rgba(255, 255, 255, 0.68);
border: 1px solid rgba(73, 57, 35, 0.1);
padding: 0.75rem 0.9rem;
}
.graph-json summary {
cursor: pointer;
font-weight: 600;
color: var(--ink);
}
.graph-json .code-block {
margin-top: 0.75rem;
}
.api-reference {
display: grid;
gap: 0.85rem;
}
.summary-box {
margin-top: 0.75rem;
padding: 0.8rem 0.9rem;
border-radius: 16px;
background: rgba(255, 255, 255, 0.74);
border: 1px solid rgba(73, 57, 35, 0.11);
display: grid;
gap: 0.35rem;
}
.summary-box strong {
color: var(--ink);
}
.endpoint-card {
border-radius: 18px;
border: 1px solid rgba(73, 57, 35, 0.11);
background: rgba(255, 255, 255, 0.74);
padding: 0.95rem 1rem;
display: grid;
gap: 0.55rem;
}
.endpoint-head {
display: flex;
gap: 0.6rem;
align-items: center;
flex-wrap: wrap;
}
.endpoint-method {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 4.4rem;
padding: 0.28rem 0.55rem;
border-radius: 999px;
background: var(--accent-2);
color: #f7fbfb;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.endpoint-path {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.9rem;
color: var(--ink);
}
@media (max-width: 1080px) {
.shell { grid-template-columns: 1fr; }
.sidebar { position: static; }
.section-grid, .split, .row, .row-3, .meta-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="shell">
<aside class="panel sidebar">
<h1>CiteGeist Literature Explorer</h1>
<p class="lede">
Lightweight HTML5 demo for topic bootstrap, topic expansion, search, rough-reference extraction,
verification, and entry inspection against the local CiteGeist explorer server.
</p>
<div class="stack">
<section class="panel card">
<h2>Bridge</h2>
<label>
Server URL
<input id="server-url" value="" />
</label>
<label>
API Token
<input id="api-token" type="password" value="" placeholder="Bearer token for /api access" />
</label>
<div class="toolbar">
<button id="connect-button" class="primary">Connect</button>
<button id="refresh-topics-button" class="tertiary">Refresh Topics</button>
<button id="api-reference-button" class="secondary">API Reference</button>
</div>
<div id="connect-status" class="status">Not connected.</div>
</section>
<section class="panel card">
<h2>Topic Bootstrap</h2>
<label>
Topic Phrase
<input id="bootstrap-topic" value="acraniates cephalochordata amphioxus lancelet" />
</label>
<div class="row">
<label>
Topic Slug
<input id="bootstrap-slug" value="acraniates" />
</label>
<label>
Topic Name
<input id="bootstrap-name" value="Acraniates" />
</label>
</div>
<div class="row-3">
<label>
Topic Limit
<input id="bootstrap-topic-limit" type="number" min="1" value="12" />
</label>
<label>
Commit Limit
<input id="bootstrap-commit-limit" type="number" min="1" value="8" />
</label>
<label>
Review Status
<input id="bootstrap-status" value="draft" />
</label>
</div>
<div class="row-3">
<label>
Expansion Mode
<select id="bootstrap-expansion-mode">
<option value="legacy">legacy</option>
<option value="cites">cites</option>
<option value="cited_by">cited_by</option>
<option value="both">both</option>
</select>
</label>
<label>
Rounds
<input id="bootstrap-expansion-rounds" type="number" min="1" value="3" />
</label>
<label>
Recent Years
<input id="bootstrap-recent-years" type="number" min="0" value="5" />
</label>
</div>
<div class="row-3">
<label>
Recent Target
<input id="bootstrap-target-recent" type="number" min="1" value="5" />
</label>
<label>
Max Expanded Entries
<input id="bootstrap-max-expanded-entries" type="number" min="1" value="100" />
</label>
<label>
Max Expand Seconds
<input id="bootstrap-max-expand-seconds" type="number" min="1" step="0.5" value="20" />
</label>
</div>
<div class="toolbar">
<button id="bootstrap-preview-button" class="secondary">Preview Bootstrap</button>
<button id="bootstrap-commit-button" class="primary">Commit Bootstrap</button>
</div>
<div id="bootstrap-summary" class="summary-box">
<strong>Bootstrap Policy</strong>
<p>Use graph-limited bootstrap when you want topic seeding and expansion in one pass. The same expansion policy applies to preview and commit.</p>
</div>
</section>
<section class="panel card">
<h2>Topic Expansion</h2>
<label>
Active Topic Slug
<input id="expand-topic-slug" value="acraniates" />
</label>
<label>
Expansion Phrase Override
<input id="expand-topic-phrase" value="acraniates cephalochordata amphioxus lancelet" />
</label>
<div class="row-3">
<label>
Expansion Source
<select id="expand-source">
<option value="openalex">openalex</option>
<option value="crossref">crossref</option>
</select>
</label>
<label>
Relation
<select id="expand-relation">
<option value="cites">cites</option>
<option value="cited_by">cited_by</option>
<option value="both">both</option>
</select>
</label>
<label>
Min Relevance
<input id="expand-min-relevance" type="number" step="0.05" min="0" max="1" value="0.2" />
</label>
</div>
<div class="row">
<label>
Seed Limit
<input id="expand-seed-limit" type="number" min="1" value="10" />
</label>
<label>
Per Seed Limit
<input id="expand-per-seed-limit" type="number" min="1" value="12" />
</label>
</div>
<div class="row-3">
<label>
Rounds
<input id="expand-rounds" type="number" min="1" value="3" />
</label>
<label>
Recent Years
<input id="expand-recent-years" type="number" min="0" value="5" />
</label>
<label>
Recent Target
<input id="expand-target-recent" type="number" min="1" value="10" />
</label>
</div>
<div class="toolbar">
<button id="expand-preview-button" class="secondary">Preview Expansion</button>
<button id="expand-commit-button" class="primary">Apply Expansion</button>
</div>
<div id="expand-summary" class="summary-box">
<strong>Expansion Policy</strong>
<p>Use <code>cites</code> to bias toward newer work, or <code>both</code> for broader graph growth. Recursive rounds stop once the recent-entry target is met.</p>
<p id="expand-source-note">Topic graph expansion currently supports openalex and crossref. Metadata search and bootstrap seeding also use datacite and pubmed.</p>
</div>
</section>
<section class="panel card">
<h2>Search</h2>
<label>
Query
<input id="search-query" value="amphioxus" />
</label>
<label>
Restrict to Topic Slug
<input id="search-topic" value="acraniates" />
</label>
<div class="toolbar">
<button id="search-button" class="primary full">Run Search</button>
</div>
</section>
<section class="panel card">
<h2>Extract + Verify</h2>
<label>
Plaintext Reference
<textarea id="extract-text">Bone, Q., 1958, The central nervous system in larval acraniates: Quarterly Journal of Microscopical Science, v. 100, p. 509-527.</textarea>
</label>
<div class="toolbar">
<button id="extract-button" class="tertiary">Extract</button>
<button id="verify-button" class="secondary">Verify String</button>
</div>
</section>
</div>
</aside>
<main class="content">
<section class="panel card">
<h2>Session</h2>
<div class="meta-grid">
<div class="meta-box">
<strong id="metric-topic-count">0</strong>
Topics
</div>
<div class="meta-box">
<strong id="metric-entry-count">0</strong>
Topic Entries
</div>
<div class="meta-box">
<strong id="metric-last-op">none</strong>
Last Operation
</div>
</div>
</section>
<section class="section-grid">
<section class="panel card">
<h2>Topics</h2>
<div id="topics-list" class="list empty">Connect to the server to load topics.</div>
</section>
<section class="panel card">
<h2>Entry Detail</h2>
<div id="entry-detail" class="empty">Select a topic entry or search result to inspect one record.</div>
</section>
</section>
<section class="split">
<section class="panel card">
<h2>Topic View</h2>
<div id="topic-view" class="empty">No topic loaded yet.</div>
</section>
<section class="panel card">
<h2>Activity</h2>
<div id="activity-log" class="code-block">Waiting for requests…</div>
</section>
</section>
<section class="split">
<section class="panel card">
<h2>Search Results</h2>
<div id="search-results" class="empty">Run a search to inspect matching entries.</div>
</section>
<section class="panel card">
<h2>Extract / Verify Output</h2>
<div id="extract-verify-output" class="empty">Extraction and verification results will appear here.</div>
</section>
</section>
<section class="panel card">
<h2>Graph View</h2>
<div id="graph-output" class="empty">Load a topic to view a small local network around its first few entries.</div>
</section>
<section id="api-reference" class="panel card">
<h2>API Reference</h2>
<div class="api-reference">
<div class="endpoint-card">
<div class="endpoint-head">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/api/healthz</span>
</div>
<p>Health check endpoint. Does not require a bearer token.</p>
</div>
<div class="endpoint-card">
<div class="endpoint-head">
<span class="endpoint-method">GET</span>
<span class="endpoint-path">/api/capabilities</span>
</div>
<p>Returns the available operation names and preview-capable actions. Requires <code>Authorization: Bearer &lt;token&gt;</code>.</p>
</div>
<div class="endpoint-card">
<div class="endpoint-head">
<span class="endpoint-method">POST</span>
<span class="endpoint-path">/api/call</span>
</div>
<p>RPC-style API entry point for search, topic loading, graph traversal, extraction, verification, and topic BibTeX export.</p>
<div class="code-block">{
"method": "expand_topic",
"params": {
"topic_slug": "acraniates",
"relation_type": "cites",
"max_rounds": 3,
"recent_years": 5,
"target_recent_entries": 10
}
}</div>
</div>
<div class="endpoint-card">
<div class="endpoint-head">
<span class="endpoint-method">POST</span>
<span class="endpoint-path">/api/call bootstrap policy</span>
</div>
<p>Bootstrap also accepts expansion policy controls when you want bounded topic seeding plus graph growth in one step.</p>
<div class="code-block">{
"method": "bootstrap",
"params": {
"topic": "abiogenesis",
"topic_slug": "abiogenesis",
"expansion_mode": "cites",
"expansion_rounds": 3,
"recent_years": 5,
"target_recent_entries": 5,
"max_expanded_entries": 100,
"max_expand_seconds": 20
}
}</div>
</div>
<div class="endpoint-card">
<div class="endpoint-head">
<span class="endpoint-method">AUTH</span>
<span class="endpoint-path">Bearer token</span>
</div>
<p>Set the token in the sidebar once. The demo stores it in localStorage and attaches it to subsequent <code>/api/*</code> requests.</p>
</div>
</div>
</section>
</main>
</div>
<script type="module">
import { createHttpBridge, createLiteratureExplorerClient } from "./literature-explorer.js";
const DEFAULT_BRIDGE_URL = window.location.origin.startsWith("http")
? `${window.location.origin}/api`
: "http://127.0.0.1:8765";
const state = {
bridgeUrl: localStorage.getItem("citegeist.bridgeUrl") || DEFAULT_BRIDGE_URL,
apiToken: localStorage.getItem("citegeist.apiToken") || "",
client: null,
topics: [],
activeTopic: null,
activeTopicEntries: [],
};
const els = {
serverUrl: document.getElementById("server-url"),
apiToken: document.getElementById("api-token"),
connectButton: document.getElementById("connect-button"),
refreshTopicsButton: document.getElementById("refresh-topics-button"),
apiReferenceButton: document.getElementById("api-reference-button"),
connectStatus: document.getElementById("connect-status"),
topicsList: document.getElementById("topics-list"),
topicView: document.getElementById("topic-view"),
entryDetail: document.getElementById("entry-detail"),
searchResults: document.getElementById("search-results"),
graphOutput: document.getElementById("graph-output"),
extractVerifyOutput: document.getElementById("extract-verify-output"),
activityLog: document.getElementById("activity-log"),
metricTopicCount: document.getElementById("metric-topic-count"),
metricEntryCount: document.getElementById("metric-entry-count"),
metricLastOp: document.getElementById("metric-last-op"),
bootstrapTopic: document.getElementById("bootstrap-topic"),
bootstrapSlug: document.getElementById("bootstrap-slug"),
bootstrapName: document.getElementById("bootstrap-name"),
bootstrapTopicLimit: document.getElementById("bootstrap-topic-limit"),
bootstrapCommitLimit: document.getElementById("bootstrap-commit-limit"),
bootstrapStatus: document.getElementById("bootstrap-status"),
bootstrapExpansionMode: document.getElementById("bootstrap-expansion-mode"),
bootstrapExpansionRounds: document.getElementById("bootstrap-expansion-rounds"),
bootstrapRecentYears: document.getElementById("bootstrap-recent-years"),
bootstrapTargetRecent: document.getElementById("bootstrap-target-recent"),
bootstrapMaxExpandedEntries: document.getElementById("bootstrap-max-expanded-entries"),
bootstrapMaxExpandSeconds: document.getElementById("bootstrap-max-expand-seconds"),
bootstrapPreviewButton: document.getElementById("bootstrap-preview-button"),
bootstrapCommitButton: document.getElementById("bootstrap-commit-button"),
bootstrapSummary: document.getElementById("bootstrap-summary"),
expandTopicSlug: document.getElementById("expand-topic-slug"),
expandTopicPhrase: document.getElementById("expand-topic-phrase"),
expandSource: document.getElementById("expand-source"),
expandRelation: document.getElementById("expand-relation"),
expandMinRelevance: document.getElementById("expand-min-relevance"),
expandSeedLimit: document.getElementById("expand-seed-limit"),
expandPerSeedLimit: document.getElementById("expand-per-seed-limit"),
expandRounds: document.getElementById("expand-rounds"),
expandRecentYears: document.getElementById("expand-recent-years"),
expandTargetRecent: document.getElementById("expand-target-recent"),
expandPreviewButton: document.getElementById("expand-preview-button"),
expandCommitButton: document.getElementById("expand-commit-button"),
expandSummary: document.getElementById("expand-summary"),
expandSourceNote: document.getElementById("expand-source-note"),
searchQuery: document.getElementById("search-query"),
searchTopic: document.getElementById("search-topic"),
searchButton: document.getElementById("search-button"),
extractText: document.getElementById("extract-text"),
extractButton: document.getElementById("extract-button"),
verifyButton: document.getElementById("verify-button"),
};
els.serverUrl.value = state.bridgeUrl;
els.apiToken.value = state.apiToken;
function setStatus(text, kind = "") {
els.connectStatus.textContent = text;
els.connectStatus.className = `status ${kind}`.trim();
}
function setBusy(button, busy) {
if (!button) return;
button.disabled = busy;
}
function logActivity(label, payload) {
const stamp = new Date().toLocaleTimeString();
const rendered = `[${stamp}] ${label}\n${JSON.stringify(payload, null, 2)}`;
els.activityLog.textContent = rendered;
}
function setLastOp(value) {
els.metricLastOp.textContent = value;
}
function renderEmpty(target, text) {
target.className = "empty";
target.textContent = text;
}
function renderTopics() {
els.metricTopicCount.textContent = String(state.topics.length);
if (!state.topics.length) {
renderEmpty(els.topicsList, "No topics in the current database.");
return;
}
els.topicsList.className = "list";
els.topicsList.innerHTML = state.topics.map((topic) => `
<div class="list-item">
<h3><a class="topic-link" href="#" data-topic-slug="${escapeHtml(topic.slug)}">${escapeHtml(topic.name)}</a></h3>
<p>${escapeHtml(topic.slug)} · ${escapeHtml(topic.source_type || "manual")}</p>
<div class="pill-row">
<span class="pill">${topic.entry_count} entries</span>
${topic.expansion_phrase ? `<span class="pill">phrase: ${escapeHtml(topic.expansion_phrase)}</span>` : ""}
</div>
</div>
`).join("");
els.topicsList.querySelectorAll("[data-topic-slug]").forEach((node) => {
node.addEventListener("click", async (event) => {
event.preventDefault();
await loadTopic(node.getAttribute("data-topic-slug"));
});
});
}
function renderTopic(payload) {
state.activeTopic = payload?.topic || null;
state.activeTopicEntries = payload?.entries || [];
els.metricEntryCount.textContent = String(state.activeTopicEntries.length);
if (!payload || !payload.topic) {
renderEmpty(els.topicView, "No topic loaded yet.");
return;
}
const topic = payload.topic;
const entries = payload.entries || [];
els.expandTopicSlug.value = topic.slug;
if (topic.expansion_phrase) {
els.expandTopicPhrase.value = topic.expansion_phrase;
}
els.topicView.className = "list";
els.topicView.innerHTML = `
<div class="list-item">
<h3>${escapeHtml(topic.name)}</h3>
<p>${escapeHtml(topic.slug)} · ${escapeHtml(topic.source_type || "manual")} · ${topic.entry_count} entries</p>
<div class="pill-row">
${topic.expansion_phrase ? `<span class="pill">${escapeHtml(topic.expansion_phrase)}</span>` : ""}
${topic.source_url ? `<span class="pill">${escapeHtml(topic.source_url)}</span>` : ""}
</div>
<div class="toolbar">
<button type="button" class="secondary" data-export-topic="${escapeHtml(topic.slug)}">Export Topic BibTeX</button>
</div>
</div>
${entries.map((entry) => `
<div class="list-item">
<h3><a class="entry-link" href="#" data-entry-key="${escapeHtml(entry.citation_key)}">${escapeHtml(entry.title || entry.citation_key)}</a></h3>
<p>${escapeHtml(entry.citation_key)} · ${escapeHtml(entry.entry_type || "misc")} · ${escapeHtml(entry.year || "n.d.")}</p>
<div class="pill-row">
<span class="pill">confidence: ${Number(entry.confidence ?? 0).toFixed(2)}</span>
<span class="pill">${escapeHtml(entry.source_label || "unknown")}</span>
</div>
</div>
`).join("")}
`;
els.topicView.querySelectorAll("[data-entry-key]").forEach((node) => {
node.addEventListener("click", async (event) => {
event.preventDefault();
await loadEntry(node.getAttribute("data-entry-key"));
});
});
els.topicView.querySelectorAll("[data-export-topic]").forEach((node) => {
node.addEventListener("click", async () => {
await exportTopicBibtex(node.getAttribute("data-export-topic"));
});
});
}
function renderEntry(entry) {
if (!entry) {
renderEmpty(els.entryDetail, "Entry not found.");
return;
}
els.entryDetail.className = "stack";
els.entryDetail.innerHTML = `
<div class="list-item">
<h3>${escapeHtml(entry.title || entry.citation_key)}</h3>
<p>${escapeHtml(entry.citation_key)} · ${escapeHtml(entry.entry_type || "misc")} · ${escapeHtml(entry.year || "n.d.")}</p>
<div class="pill-row">
${(entry.topics || []).map((topic) => `<span class="pill">${escapeHtml(topic.slug)}</span>`).join("")}
</div>
</div>
<div class="code-block">${escapeHtml(JSON.stringify(entry, null, 2))}</div>
`;
}
function renderSearch(payload) {
const results = payload?.results || [];
if (!results.length) {
renderEmpty(els.searchResults, "No matching entries.");
return;
}
els.searchResults.className = "list";
els.searchResults.innerHTML = results.map((row) => `
<div class="list-item">
<h3><a class="entry-link" href="#" data-entry-key="${escapeHtml(row.citation_key)}">${escapeHtml(row.title || row.citation_key)}</a></h3>
<p>${escapeHtml(row.citation_key)} · ${escapeHtml(row.year || "n.d.")} · score ${Number(row.score ?? 0).toFixed(3)}</p>
</div>
`).join("");
els.searchResults.querySelectorAll("[data-entry-key]").forEach((node) => {
node.addEventListener("click", async (event) => {
event.preventDefault();
await loadEntry(node.getAttribute("data-entry-key"));
});
});
}
function renderGraph(payload) {
if (!payload || !payload.nodes?.length) {
renderEmpty(els.graphOutput, "No graph payload available.");
return;
}
const nodes = payload.nodes || [];
const edges = payload.edges || [];
const width = 900;
const height = 360;
const centerX = width / 2;
const centerY = height / 2;
const ringRadius = Math.min(width, height) * 0.32;
const laidOut = layoutGraphNodes(nodes, centerX, centerY, ringRadius);
const nodeById = new Map(laidOut.map((node) => [node.id, node]));
const edgeMarkup = edges.map((edge) => {
const source = nodeById.get(edge.source);
const target = nodeById.get(edge.target);
if (!source || !target) return "";
return `<line class="graph-edge" x1="${source.x}" y1="${source.y}" x2="${target.x}" y2="${target.y}"></line>`;
}).join("");
const nodeMarkup = laidOut.map((node) => {
const fill = graphNodeColor(node);
const radius = node.is_seed ? 13 : 9;
const label = escapeHtml(graphNodeLabel(node));
return `
<g class="graph-node" data-entry-key="${escapeHtml(node.id)}" transform="translate(${node.x}, ${node.y})">
<circle r="${radius}" fill="${fill}"></circle>
<text class="graph-label" x="${radius + 6}" y="4">${label}</text>
</g>
`;
}).join("");
const seedCount = laidOut.filter((node) => node.is_seed).length;
const resolvedCount = laidOut.filter((node) => node.target_exists !== false).length;
els.graphOutput.className = "graph-shell";
els.graphOutput.innerHTML = `
<div class="graph-meta">
<span class="pill">${laidOut.length} nodes</span>
<span class="pill">${edges.length} edges</span>
<span class="pill">${seedCount} seeds</span>
<span class="pill">${resolvedCount} stored entries</span>
</div>
<div class="graph-legend">
<span class="legend-item"><span class="legend-swatch" style="background:#8d3f2d"></span>Seed entry</span>
<span class="legend-item"><span class="legend-swatch" style="background:#2f5f5a"></span>Stored neighbor</span>
<span class="legend-item"><span class="legend-swatch" style="background:#9f7a18"></span>External / unresolved</span>
</div>
<div class="graph-canvas">
<svg class="graph-svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="Entry relationship graph">
${edgeMarkup}
${nodeMarkup}
</svg>
</div>
<div class="graph-json">
<details>
<summary>Raw graph payload</summary>
<div class="code-block">${escapeHtml(JSON.stringify(payload, null, 2))}</div>
</details>
</div>
`;
els.graphOutput.querySelectorAll("[data-entry-key]").forEach((node) => {
node.addEventListener("click", async () => {
await loadEntry(node.getAttribute("data-entry-key"));
});
});
}
function renderExtractVerify(payload) {
els.extractVerifyOutput.className = "code-block";
els.extractVerifyOutput.textContent = JSON.stringify(payload, null, 2);
}
function renderExpandSummary(payload) {
if (!els.expandSummary) return;
const results = payload?.results || [];
const assigned = results.filter((item) => item.assigned_to_topic).length;
const runMeta = payload?.run_meta || {};
els.expandSummary.innerHTML = `
<strong>Expansion Summary</strong>
<p>${results.length} discoveries returned · ${assigned} assigned to topic · relation ${escapeHtml(els.expandRelation.value)} · rounds ${escapeHtml(els.expandRounds.value)}</p>
<p>Recent target: ${escapeHtml(els.expandTargetRecent.value)} within ${escapeHtml(els.expandRecentYears.value)} years. Stop reason: <strong>${escapeHtml(runMeta.stop_reason || "unknown")}</strong>.</p>
<p>Recent hits: ${escapeHtml(runMeta.recent_hits ?? 0)} · recent topic hits: ${escapeHtml(runMeta.recent_topic_hits ?? 0)}.</p>
`;
}
function renderBootstrapSummary(payload) {
if (!els.bootstrapSummary) return;
const results = payload?.results || [];
const created = results.filter((item) => item.created).length;
const runMeta = payload?.run_meta || {};
els.bootstrapSummary.innerHTML = `
<strong>Bootstrap Summary</strong>
<p>${results.length} candidate entries returned · ${created} newly created in this pass · mode ${escapeHtml(els.bootstrapExpansionMode.value)} · rounds ${escapeHtml(els.bootstrapExpansionRounds.value)}</p>
<p>Recent target: ${escapeHtml(els.bootstrapTargetRecent.value)} within ${escapeHtml(els.bootstrapRecentYears.value)} years · caps ${escapeHtml(els.bootstrapMaxExpandedEntries.value)} entries / ${escapeHtml(els.bootstrapMaxExpandSeconds.value)} seconds.</p>
<p>Stop reason: <strong>${escapeHtml(runMeta.stop_reason || "unknown")}</strong> · expanded discoveries: ${escapeHtml(runMeta.expanded_discoveries ?? 0)} · recent topic hits: ${escapeHtml(runMeta.recent_topic_hits ?? 0)}.</p>
`;
}
function populateSelectOptions(select, values, preferredValue) {
if (!select || !Array.isArray(values) || !values.length) return;
const previousValue = select.value;
select.innerHTML = "";
values.forEach((value) => {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
select.appendChild(option);
});
if (values.includes(previousValue)) {
select.value = previousValue;
} else if (values.includes(preferredValue)) {
select.value = preferredValue;
} else {
select.value = values[0];
}
}
function applyCapabilities(capabilities) {
const topicExpansionSources = capabilities?.topic_expansion_sources || capabilities?.graph_expansion_sources || [];
const relationTypes = capabilities?.graph_relation_types || [];
const metadataSources = capabilities?.metadata_sources || [];
if (topicExpansionSources.length) {
populateSelectOptions(els.expandSource, topicExpansionSources, "openalex");
}
if (relationTypes.length) {
populateSelectOptions(els.expandRelation, relationTypes, "cites");
}
if (els.expandSourceNote) {
const expansionText = topicExpansionSources.length ? topicExpansionSources.join(", ") : "openalex, crossref";
const metadataText = metadataSources.length ? metadataSources.join(", ") : "crossref, datacite, openalex, pubmed";
els.expandSourceNote.textContent =
`Topic graph expansion currently supports ${expansionText}. Metadata search and bootstrap seeding also use ${metadataText}.`;
}
}
async function connect() {
setBusy(els.connectButton, true);
try {
state.bridgeUrl = els.serverUrl.value.trim() || state.bridgeUrl;
state.apiToken = els.apiToken.value.trim();
localStorage.setItem("citegeist.bridgeUrl", state.bridgeUrl);
localStorage.setItem("citegeist.apiToken", state.apiToken);
const bridge = createHttpBridge(state.bridgeUrl, { token: state.apiToken });
const client = createLiteratureExplorerClient(bridge);
const capabilities = await client.capabilities();
state.client = client;
applyCapabilities(capabilities);
setStatus(`Connected to ${state.bridgeUrl}`, "ok");
setLastOp("connect");
logActivity("capabilities", capabilities);
await refreshTopics();
} catch (error) {
setStatus(String(error.message || error), "error");
} finally {
setBusy(els.connectButton, false);
}
}
async function refreshTopics() {
if (!state.client) {
setStatus("Connect to the server first.", "error");
return;
}
setBusy(els.refreshTopicsButton, true);
try {
const payload = await state.client.listTopics();
state.topics = payload.topics || [];
renderTopics();
setLastOp("list_topics");
logActivity("list_topics", payload);
} catch (error) {
setStatus(String(error.message || error), "error");
} finally {
setBusy(els.refreshTopicsButton, false);
}
}
async function loadTopic(topicSlug) {
if (!state.client || !topicSlug) return;
const payload = await state.client.getTopic(topicSlug, { entry_limit: 200 });
renderTopic(payload);
setLastOp("get_topic");
logActivity(`get_topic:${topicSlug}`, payload);
if (payload?.entries?.length) {
const seedKeys = payload.entries.slice(0, Math.min(5, payload.entries.length)).map((entry) => entry.citation_key);
const graphPayload = await state.client.graph(seedKeys, { depth: 1 });
renderGraph(graphPayload);
} else {
renderGraph(null);
}
}
async function loadEntry(citationKey) {
if (!state.client || !citationKey) return;
const payload = await state.client.showEntry(citationKey, { include_bibtex: true, include_provenance: true });
renderEntry(payload);
setLastOp("show_entry");
logActivity(`show_entry:${citationKey}`, payload);
}
async function runBootstrap(previewOnly) {
if (!state.client) {
setStatus("Connect to the server first.", "error");
return;
}
const button = previewOnly ? els.bootstrapPreviewButton : els.bootstrapCommitButton;
setBusy(button, true);
try {
const payload = await state.client.bootstrap({
topic: els.bootstrapTopic.value.trim(),
topic_slug: els.bootstrapSlug.value.trim() || undefined,
topic_name: els.bootstrapName.value.trim() || undefined,
topic_phrase: els.bootstrapTopic.value.trim(),
topic_limit: Number(els.bootstrapTopicLimit.value || 5),
topic_commit_limit: Number(els.bootstrapCommitLimit.value || 0) || null,
preview_only: previewOnly,
expand: els.bootstrapExpansionMode.value !== "legacy",
review_status: els.bootstrapStatus.value.trim() || "draft",
expansion_mode: els.bootstrapExpansionMode.value,
expansion_rounds: Number(els.bootstrapExpansionRounds.value || 1),
recent_years: Number(els.bootstrapRecentYears.value || 0) || null,
target_recent_entries: Number(els.bootstrapTargetRecent.value || 0) || null,
max_expanded_entries: Number(els.bootstrapMaxExpandedEntries.value || 0) || null,
max_expand_seconds: Number(els.bootstrapMaxExpandSeconds.value || 0) || null,
});
renderExtractVerify(payload);
renderBootstrapSummary(payload);
setLastOp(previewOnly ? "bootstrap_preview" : "bootstrap_commit");
logActivity(previewOnly ? "bootstrap_preview" : "bootstrap_commit", payload);
if (!previewOnly) {
await refreshTopics();
if (els.bootstrapSlug.value.trim()) {
await loadTopic(els.bootstrapSlug.value.trim());
}
}
} catch (error) {
setStatus(String(error.message || error), "error");
} finally {
setBusy(button, false);
}
}
async function runExpand(previewOnly) {
if (!state.client) {
setStatus("Connect to the server first.", "error");
return;
}
const button = previewOnly ? els.expandPreviewButton : els.expandCommitButton;
setBusy(button, true);
try {
const topicSlug = els.expandTopicSlug.value.trim();
const payload = await state.client.expandTopic(topicSlug, {
topic_phrase: els.expandTopicPhrase.value.trim() || undefined,
source: els.expandSource.value,
relation_type: els.expandRelation.value,
min_relevance: Number(els.expandMinRelevance.value || 0.2),
seed_limit: Number(els.expandSeedLimit.value || 10),
per_seed_limit: Number(els.expandPerSeedLimit.value || 12),
max_rounds: Number(els.expandRounds.value || 1),
recent_years: Number(els.expandRecentYears.value || 0),
target_recent_entries: Number(els.expandTargetRecent.value || 0) || null,
preview_only: previewOnly,
});
renderExtractVerify(payload);
renderExpandSummary(payload);
setLastOp(previewOnly ? "expand_preview" : "expand_commit");
logActivity(previewOnly ? "expand_preview" : "expand_commit", payload);
if (!previewOnly && topicSlug) {
await loadTopic(topicSlug);
await refreshTopics();
}
} catch (error) {
setStatus(String(error.message || error), "error");
} finally {
setBusy(button, false);
}
}
async function runSearch() {
if (!state.client) {
setStatus("Connect to the server first.", "error");
return;
}
setBusy(els.searchButton, true);
try {
const payload = await state.client.search(els.searchQuery.value.trim(), {
topic_slug: els.searchTopic.value.trim() || undefined,
limit: 20,
});
renderSearch(payload);
setLastOp("search");
logActivity("search", payload);
} catch (error) {
setStatus(String(error.message || error), "error");
} finally {
setBusy(els.searchButton, false);
}
}
async function runExtract() {
if (!state.client) {
setStatus("Connect to the server first.", "error");
return;
}
setBusy(els.extractButton, true);
try {
const payload = await state.client.extractText(els.extractText.value, { backend: "heuristic" });
renderExtractVerify(payload);
setLastOp("extract_text");
logActivity("extract_text", payload);
} catch (error) {
setStatus(String(error.message || error), "error");
} finally {
setBusy(els.extractButton, false);
}
}
async function runVerify() {
if (!state.client) {
setStatus("Connect to the server first.", "error");
return;
}
setBusy(els.verifyButton, true);
try {
const payload = await state.client.verifyStrings([els.extractText.value], { limit: 5 });
renderExtractVerify(payload);
setLastOp("verify_strings");
logActivity("verify_strings", payload);
} catch (error) {
setStatus(String(error.message || error), "error");
} finally {
setBusy(els.verifyButton, false);
}
}
async function exportTopicBibtex(topicSlug) {
if (!state.client || !topicSlug) {
setStatus("Connect to the server first.", "error");
return;
}
try {
const payload = await state.client.exportTopicBibtex(topicSlug, { include_stubs: false });
const filename = `${topicSlug}.bib`;
downloadText(filename, payload?.bibtex || "");
renderExtractVerify(payload);
setLastOp("export_topic_bibtex");
logActivity(`export_topic_bibtex:${topicSlug}`, payload);
const skippedCount = Array.isArray(payload?.skipped) ? payload.skipped.length : 0;
if (skippedCount) {
setStatus(`Exported ${filename} with ${skippedCount} skipped malformed entr${skippedCount === 1 ? "y" : "ies"}.`, "ok");
} else {
setStatus(`Exported ${filename}`, "ok");
}
} catch (error) {
setStatus(String(error.message || error), "error");
}
}
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function graphNodeLabel(node) {
return node.title || node.id || "entry";
}
function graphNodeColor(node) {
if (node.is_seed) return "#8d3f2d";
if (node.target_exists === false) return "#9f7a18";
return "#2f5f5a";
}
function layoutGraphNodes(nodes, centerX, centerY, ringRadius) {
const seeds = nodes.filter((node) => node.is_seed);
const others = nodes.filter((node) => !node.is_seed);
const laidOutSeeds = layoutRing(seeds, centerX, centerY, ringRadius * 0.42);
const laidOutOthers = layoutRing(others, centerX, centerY, ringRadius);
return [...laidOutSeeds, ...laidOutOthers];
}
function layoutRing(nodes, centerX, centerY, radius) {
if (!nodes.length) return [];
if (nodes.length === 1) {
return [{ ...nodes[0], x: centerX, y: centerY }];
}
return nodes.map((node, index) => {
const angle = (-Math.PI / 2) + ((Math.PI * 2 * index) / nodes.length);
return {
...node,
x: centerX + Math.cos(angle) * radius,
y: centerY + Math.sin(angle) * radius,
};
});
}
function downloadText(filename, text) {
const blob = new Blob([text], { type: "application/x-bibtex; charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
els.connectButton.addEventListener("click", connect);
els.refreshTopicsButton.addEventListener("click", refreshTopics);
els.apiReferenceButton.addEventListener("click", () => {
document.getElementById("api-reference").scrollIntoView({ behavior: "smooth", block: "start" });
});
els.bootstrapPreviewButton.addEventListener("click", () => runBootstrap(true));
els.bootstrapCommitButton.addEventListener("click", () => runBootstrap(false));
els.expandPreviewButton.addEventListener("click", () => runExpand(true));
els.expandCommitButton.addEventListener("click", () => runExpand(false));
els.searchButton.addEventListener("click", runSearch);
els.extractButton.addEventListener("click", runExtract);
els.verifyButton.addEventListener("click", runVerify);
</script>
</body>
</html>