Search papers, map their citation networks, and find out how any two papers are actually connected.
Live demo → · 中文说明 · Architecture
The public demo is open without a token. It has a daily visitor/request budget and returns a friendly 429 when the budget is exhausted, so a burst of traffic cannot run up API or LLM bills. Run CiteDuo locally for unlimited use.
Self-hosted and local-first: one Bun process, one SQLite file, no account, no cloud.
Most citation-graph tools show you the neighbourhood of one paper. That answers "what is around this paper?" — but not the question you have when two papers are already in front of you: "how are these two related?"
CiteDuo is built around that second question, and around owning your own data.
| Typical citation-graph tool | CiteDuo | |
|---|---|---|
| Neighbourhood graph | ✅ | ✅ |
| "How are these two papers connected?" | ❌ | ✅ an explained path, hop by hop, with alternatives |
| Runs on your own machine | ❌ | ✅ single Bun process + SQLite file |
| Read + translate the paper in-app | ❌ | ✅ arXiv HTML reader with bilingual mode |
| Ask an AI that has actually read the paper | ❌ | ✅ retrieval over the full text before it answers |
| Notes, favourites, collections | limited | ✅ local, exportable |
Pick any two papers and the app searches for the chain that links them, then explains each hop in plain language.
- An answer, not just a graph. "Both are cited by Uni-AdaFocus, so they are usually discussed together" — that is the whole point.
- Paths are ranked by evidence. Direct reference (1.0) › citation (0.95) › bibliographic coupling (0.8) › related work (0.6) › embedding similarity (0.45). A one-hop answer only counts as a direct citation when the hop really is one: a single coupling edge is labelled coupling, and a symmetric
related/semanticedge is a similarity bridge rather than a made-up citation. Longer routes are labelled by shape — coupling, co-citation, citation path or similarity bridge. - Alternatives are shown, so you can tell a robust connection from a coincidental one.
- Signals alongside the path: shared references, shared citers, embedding similarity, shared fields, shared authors.
- Four tiers, cheapest first: fetch the two endpoints best-effort → walk relations already stored locally → a budgeted live crawl → an embedding bridge as a last resort. Ask for a local-only answer when you want it instantly and offline.
- Search across Semantic Scholar and OpenAlex, merged and de-duplicated by DOI/arXiv id, with locally remembered keyword history.
- Interactive citation network — PageRank-weighted nodes, Louvain community clustering, per-edge-type visibility toggles, and a timeline you can scrub or play back to see a field grow.
- 2D canvas or 3D force graph, whichever suits the question.
- Node context menu: rebuild the graph from here, re-search by title, expand this node, add to comparison, open the original.
- Multi-source relation edges: citations and references, Semantic Scholar recommendations, OpenAlex related works (with an arXiv title fallback), bibliographic coupling, and SPECTER2 embedding kNN — normalised so the same paper never appears twice.
- Graph caching of nodes, edges and layout, so reopening a network is instant instead of re-running the simulation.
Two papers side by side, each with its own citation graph, sharing the filters and encodings — or hand the pair straight to the relation analysis with one click.
- In-app reading of arXiv HTML with a section outline; papers without an HTML build fall back to abs/PDF.
- Immersive bilingual translation per paragraph, with any OpenAI-compatible provider, or entirely in-browser via the built-in Translator API.
- AI reading assistant driven by a local
opencodeagent. It calls retrieval tools (paper_search,paper_section) before answering and streams what it is doing ("searching…", "reading section 3"), so answers come from the paper rather than the model's memory. - Reading queue and progress — to-read / reading / done, plus scroll progress per paper.
- Highlights and annotations in four colours, stored locally.
- Notes, favourites, collections and saved searches, all local.
- Import/export: PNG of the current view, JSON, BibTeX, CSV, or the full crawled network as JSON.
- Shareable deep links. The paper, filters, encoding, view mode and the compared pair all round-trip through the URL — including
?from=<id>&to=<id>, which re-runs a relation analysis for whoever opens it. - Optional access token, or a public-demo daily visitor/request budget if you want the GitHub demo open to everyone.
Nothing installed but Docker? One command, no account, no config file:
docker run -d --name citeduo -p 8787:8787 -v citeduo-data:/data ghcr.io/benbenlijie/citeduo:latest
# → http://127.0.0.1:8787Or with compose, which sets PAPER_CONTENT_MODE=full for a single-user instance: docker compose up --build.
Requires Bun ≥ 1.3 and pnpm 9.
git clone https://github.com/benbenlijie/citeduo.git
cd citeduo
bash scripts/setup.sh # installs both dependency trees, creates data/, copies server/.env
bun run build:web # build the frontend
bun run server # http://127.0.0.1:8787That is a fully working local instance. Searching works immediately; adding a free SEMANTIC_SCHOLAR_API_KEY to server/.env makes upstream calls much more comfortable.
Working on the frontend (/api is proxied to 8787, with hot reload):
bun run server # terminal A
bun run dev:web # terminal Bflowchart LR
UI["Browser<br/>React 18 · Vite · Tailwind<br/>react-force-graph"] -->|"/api/*"| S["Bun server<br/>Bun.serve"]
S --> DB[("SQLite<br/>data/app.db")]
S -->|"search · refs · cites"| S2["Semantic Scholar"]
S -->|"search · related works"| OA["OpenAlex"]
S -->|"embeddings"| SP["SPECTER2"]
S -.->|"chat · translation"| LLM["LLM provider<br/>(optional)"]
S -.->|"agent tools"| OC["opencode serve<br/>(optional)"]
Everything is one process: the Bun server serves the built frontend, the JSON API, and the SQLite database. Upstream responses are persisted, so graphs, relation edges and paper text get faster the more you use it — and a local-only relation answer needs no network at all.
citeduo/
├── academic-paper-explorer/ # React frontend (src/, components/, graph/, store/)
├── server/ # Bun backend: routes, upstream clients, SQLite, graph search
│ ├── pathfind.ts # pure path search + ranking + explanation
│ └── connect.ts # the four-tier relation search
├── scripts/ # setup.sh, deploy.sh, opencode-local.ts
└── docs/images/ # screenshots used by this README
See ARCHITECTURE.md for the long version.
server/.env (copied from server/.env.example by setup.sh). Everything is optional — the defaults run offline against an empty database.
| Variable | Default | Purpose |
|---|---|---|
PORT |
8787 |
Listen port (loopback only). |
HOST |
127.0.0.1 |
Set to 0.0.0.0 only if you add your own TLS/reverse proxy. |
ACCESS_TOKEN |
– | If set, the whole app requires this token (?token=… sets a cookie). Leave unset for an open instance. |
ADMIN_TOKEN |
– | Optional separate password for the usage dashboard. Falls back to ACCESS_TOKEN. Required for a public demo. |
DEMO_PUBLIC |
0 |
1 enables the public-demo budget and requires ADMIN_TOKEN for the dashboard; it does not disable app features. |
DEMO_DAILY_VISITOR_LIMIT |
0 |
Daily unique-visitor cap for the public demo; 0 = unlimited. |
DEMO_DAILY_REQUEST_LIMIT |
0 |
Daily HTML/API request cap for the public demo; 0 = unlimited. |
DEMO_BLOCK_ON_LIMIT |
1 |
0 = alert only; the default returns 429 for the rest of the day. |
DEMO_ALERT_WEBHOOK |
– | Optional webhook for the one-time over-limit alert; otherwise the alert goes to the journal. |
DEMO_ALERT_EMAIL |
– | Optional recipient for the over-limit email. Requires SMTP_HOST / SMTP_USER / SMTP_PASS; QQ Mail needs an authorization code, not the login password. |
ANALYTICS_ENABLED |
1 |
0 disables first-party usage collection entirely. |
RATE_LIMIT_PER_MIN |
120 |
Per-IP requests/minute on /api/*. 0 disables. |
TRUST_PROXY |
off | Read the client IP from X-Real-IP / X-Forwarded-For — only behind a proxy you control. |
SEMANTIC_SCHOLAR_API_KEY |
– | Without it you share S2's anonymous rate limit. |
OPENALEX_API_KEY |
– | Avoids OpenAlex's anonymous limit. |
S2_MIN_INTERVAL_MS |
1000 |
Minimum spacing between S2 calls, with or without a key. |
OPENALEX_MIN_INTERVAL_MS |
– | Same, for OpenAlex. |
CONNECT_MAX_EXPANSIONS |
6 |
Live S2 batch fetches allowed per relation search. |
CONNECT_MAX_MS |
25000 |
Wall-clock budget for one relation search. |
CONNECT_LOCAL_ONLY |
off | 1 = answer only from stored relations, never fetch. |
PAPER_CONTENT_MODE |
auto |
full / off / auto; auto means full text only on a loopback instance, and abstracts plus an arXiv link anywhere else. |
ARXIV_API_MIN_INTERVAL_MS |
3000 |
arXiv's API terms ask for at most one request every three seconds. |
ARXIV_CONTENT_MIN_INTERVAL_MS |
1000 |
Floor between arXiv paper-page fetches. Raise to 15000 for a literal reading of robots.txt. |
ARXIV_CONTENT_MAX_PER_HOUR |
60 |
Circuit breaker on paper-page fetches per hour. |
ABSTRACT_MEMO_MAX |
500 |
Cap on in-memory CC0 abstracts when PAPER_CONTENT_MODE=off; oldest entries are evicted first. 0 disables the memo. Non-numeric values fall back to 500. |
The complete list, including the crawl and translation knobs, is in server/.env.example.
Usage analytics — meaningful actions are appended to usage_events in the same SQLite file (data/app.db): page views, searches, graph builds, reader opens, translations and AI sessions. The dashboard is at /stats (/papers/stats when mounted under a sub-path) and is protected by ACCESS_TOKEN; set ADMIN_TOKEN to give it a separate password and open /stats?token=… once. Page views are reported by the SPA on route changes; API events are recorded server-side. The table stores an anonymous first-party cookie id, not raw IPs or user agents, and strips query strings from page paths and referrers so an access token can never end up in analytics.
Public demo — leave ACCESS_TOKEN unset, set ADMIN_TOKEN (the dashboard/admin APIs still need it), and set DEMO_DAILY_VISITOR_LIMIT / DEMO_DAILY_REQUEST_LIMIT. Request counting covers HTML navigations and API calls, not static assets. When either cap is crossed, the current day returns 429 until local midnight; DEMO_BLOCK_ON_LIMIT=0 switches to alert-only. Set DEMO_ALERT_WEBHOOK plus DEMO_ALERT_FORMAT=generic|slack|discord|feishu for a webhook, or DEMO_ALERT_EMAIL plus SMTP_HOST/SMTP_USER/SMTP_PASS for email; otherwise the alert goes to the journal. POST /api/admin/demo-quota/test-email sends a test message. GET /api/admin/demo-quota shows the state and POST /api/admin/demo-quota/reset is the emergency reset. All app features remain available to anonymous visitors; DEMO_PUBLIC=1 only enables the budget and requires ADMIN_TOKEN for the dashboard/admin APIs. For a paid deployment, put a spending cap on the provider account and monitor the quota webhook. If you prefer a lower-risk demo, set PAPER_CONTENT_MODE=off and use browser-only translation.
Reader and arXiv usage — the in-app reader can show a paper's full text, but arXiv's terms of use only allow storing and serving e-print content "for your own personal use, or for research purposes" (tou.html). PAPER_CONTENT_MODE therefore defaults to auto: full on a loopback-only instance, off anywhere else. With off the reader returns the abstract, the metadata and a link to the paper's arXiv page, and writes nothing to disk. The hosted demo currently uses full at the operator's explicit request; that is the setting with arXiv-terms risk. A conservative deployment should use off (direct browser arXiv HTML still loads where arXiv provides it); permissive licences could later be detected per paper and served individually.
LLM / translation provider — an ordered fallback list; kind: "browser" needs no key and runs entirely in the visitor's browser:
LLM_PROVIDERS=[{"name":"local","kind":"openai","baseUrl":"https://<host>/v1","apiKey":"sk-...","model":"..."},{"name":"browser","kind":"browser"}]AI assistant — token-gated by default; the rest of the site can stay public. Local spawn needs the opencode binary; the recommended setup is the isolated container in docker-compose.opencode.yml:
AI_ACCESS_TOKEN=a-long-random-secret # required; falls back to ACCESS_TOKEN; neither => /api/ai/* 503
# Open /ai-login (e.g. https://your-domain/ai-login) and enter the token; it is stored in the cp_ai cookie.
# The token is submitted as a POST body, never in the URL or nginx access log.
OPENCODE_ENABLED=1 # 0 disables the assistant
OPENCODE_BIN=opencode # absolute path if it is not on PATH (local spawn only)
OPENCODE_PORT=4096
# Option B: connect to the isolated container; must match OPENCODE_SERVER_PASSWORD there
# OPENCODE_BASE_URL=http://127.0.0.1:4096
# OPENCODE_SERVER_PASSWORD=another-long-random-secret
AI_MAX_STEPS=8 # tool steps per turn
PAPER_CONTENT_TTL_HOURS=168 # paper-text cache TTL
INTERNAL_TOKEN= # shared secret for the internal retrieval API; random per boot if unsetWithout AI_ACCESS_TOKEN (and no ACCESS_TOKEN), without an openai provider, or with opencode unavailable, /api/ai/* returns 503 and translation is unaffected. Token-bearing API calls may also use Authorization: Bearer <token>.
bun run test:server # backend
pnpm --dir academic-paper-explorer test # frontend
bun run typecheck:server
pnpm --dir academic-paper-explorer typecheck
pnpm --dir academic-paper-explorer lintCI runs exactly these on every push and pull request. See CONTRIBUTING.md for the commit convention and the development workflow.
bun run deploy builds the frontend and rsyncs the tree to a host you configure in scripts/deploy.sh, then restarts the service and health-checks the URL. The bundled guide covers a sub-path deployment behind nginx with TLS, the token gate, and running it as a systemd unit — see DEPLOYMENT_GUIDE.md.
The live demo is open without a token and keeps the full feature set — full-text reader, server-side translation and the AI assistant — under a daily visitor/request budget; after the cap it returns
429until local midnight. It exists to try the app, not to hold your data.
- Export a relation path as a citable figure / BibTeX snippet.
- Zotero and BibTeX library import.
- Full-text search over locally cached paper text.
- Postgres backend for multi-user deployments.
Ideas and bug reports are welcome in issues.
This is not the first tool in this space, and it does not try to replace the good ones. Connected Papers, Litmaps, ResearchRabbit and Inciteful all do citation-neighbourhood exploration well. CiteDuo deliberately differs in three ways: it answers for a pair of papers with a ranked, explained path rather than only drawing a neighbourhood; it runs entirely on your own machine instead of as an account-based web service; and its assistant has to retrieve the paper text before it answers.
Contributions are welcome — see CONTRIBUTING.md and CODE_OF_CONDUCT.md. Security issues: please follow SECURITY.md instead of opening a public issue.
MIT.






