CiteGeist/examples/literature-explorer/index.html

1569 lines
55 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);
}
.claim-stack {
display: grid;
gap: 0.85rem;
}
.claim-card {
padding: 0.95rem 1rem;
border-radius: 18px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(73, 57, 35, 0.11);
display: grid;
gap: 0.6rem;
}
.claim-score {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.03em;
color: #6b230f;
background: #f4dfd3;
border: 1px solid rgba(141, 63, 45, 0.16);
border-radius: 999px;
padding: 0.3rem 0.62rem;
width: fit-content;
}
.claim-text {
color: var(--ink);
line-height: 1.5;
}
.claim-note {
font-size: 0.88rem;
color: var(--muted);
}
.claim-ref-list {
display: grid;
gap: 0.55rem;
}
.claim-ref {
padding: 0.75rem 0.85rem;
border-radius: 14px;
background: rgba(245, 239, 229, 0.68);
border: 1px solid rgba(73, 57, 35, 0.09);
}
.claim-ref strong {
display: block;
margin-bottom: 0.15rem;
}
.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>
<section class="panel card">
<h2>Claim Support</h2>
<label>
Claim-Like Excerpt
<textarea id="claim-support-text">Computational research touching on movement of agents spans many different fields. Movement may not be modeled at all, but simply assigned a cost value, as in work in artificial neural systems applied to the traveling salesman problem [1]. Our research takes an approach at an intermediate level, seeking to elucidate how evolutionary processes can result in individual control of existing movement capabilities in order to intelligently exploit environmental resources.</textarea>
</label>
<div class="row-3">
<label>
Context
<input id="claim-support-context" value="artificial life" />
</label>
<label>
Max Claims
<input id="claim-support-max-claims" type="number" min="1" value="5" />
</label>
<label>
Min Claim Chars
<input id="claim-support-min-chars" type="number" min="20" value="80" />
</label>
</div>
<div class="toolbar">
<button id="claim-support-button" class="primary full">Suggest Support</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>Claim Support Review</h2>
<div id="claim-support-output" class="empty">Run claim support to rank support-worthy assertions and inspect suggested references.</div>
</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"),
claimSupportOutput: document.getElementById("claim-support-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"),
claimSupportText: document.getElementById("claim-support-text"),
claimSupportContext: document.getElementById("claim-support-context"),
claimSupportMaxClaims: document.getElementById("claim-support-max-claims"),
claimSupportMinChars: document.getElementById("claim-support-min-chars"),
claimSupportButton: document.getElementById("claim-support-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 renderClaimSupport(payload) {
const suggestions = payload?.suggestions || [];
if (!suggestions.length) {
renderEmpty(els.claimSupportOutput, "No ranked support suggestions yet. Try a longer excerpt or a different context phrase.");
return;
}
els.claimSupportOutput.className = "claim-stack";
els.claimSupportOutput.innerHTML = `
<div class="summary-box">
<strong>Claim Support Summary</strong>
<p>${suggestions.length} ranked claims from ${payload.claim_count || 0} extracted candidates · ${payload.existing_reference_count || 0} parsed existing references.</p>
<p>Claims are ordered by <code>needs_support_score</code>, so uncited or under-supported assertions appear first.</p>
</div>
${suggestions.map((suggestion) => `
<div class="claim-card">
<span class="claim-score">Needs Support ${Number(suggestion.needs_support_score ?? 0).toFixed(3)}</span>
<div class="claim-text">${escapeHtml(suggestion.claim_text || "")}</div>
<div class="pill-row">
${(suggestion.existing_citation_markers || []).map((marker) => `<span class="pill">${escapeHtml(marker)}</span>`).join("") || '<span class="pill">no inline citations detected</span>'}
</div>
${suggestion.note ? `<div class="claim-note">${escapeHtml(suggestion.note)}</div>` : ""}
<div class="claim-ref-list">
${(suggestion.suggested_references || []).map((reference) => `
<div class="claim-ref">
<strong>${escapeHtml(reference.title || reference.citation_key || "candidate")}</strong>
<p>${escapeHtml(reference.authors || "Unknown authors")} · ${escapeHtml(reference.year || "n.d.")} · score ${Number(reference.score ?? 0).toFixed(3)}</p>
<div class="pill-row">
${reference.journal ? `<span class="pill">${escapeHtml(reference.journal)}</span>` : ""}
${reference.doi ? `<span class="pill">${escapeHtml(reference.doi)}</span>` : ""}
${reference.source_label ? `<span class="pill">${escapeHtml(reference.source_label)}</span>` : ""}
</div>
</div>
`).join("")}
</div>
</div>
`).join("")}
`;
}
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 runClaimSupport() {
if (!state.client) {
setStatus("Connect to the server first.", "error");
return;
}
setBusy(els.claimSupportButton, true);
try {
const payload = await state.client.supportClaims(els.claimSupportText.value, {
context: els.claimSupportContext.value.trim(),
limit: 5,
max_claims: Number(els.claimSupportMaxClaims.value || 5),
min_claim_chars: Number(els.claimSupportMinChars.value || 80),
});
renderClaimSupport(payload);
setLastOp("support_claims");
logActivity("support_claims", payload);
} catch (error) {
setStatus(String(error.message || error), "error");
} finally {
setBusy(els.claimSupportButton, 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);
els.claimSupportButton.addEventListener("click", runClaimSupport);
</script>
</body>
</html>