The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Sigao Li — personal MCP server listing page.
Personal website of Sigao Li — AI Product Manager · Spatial Data Scientist. From maps to models, and the products in between.
Bilingual (English at /, 中文 at /zh/), built with Astro + Tailwind CSS v4 + GSAP,
deployed to GitHub Pages via GitHub Actions. Launched 2026-06-11, replacing the previous
Jekyll (academicpages) site.
docs/)/llms.txt, /llms-full.txt, /resume.json (JSON Resume), /knowledge.json, /.well-known/mcp.json, JSON-LD, and a robots.txt that explicitly welcomes AI crawlers_inbox/ and commit. A pre-commit hook works out the country from the coordinates against Natural Earth polygons, archives the originals outside git, derives 2560px masters with the EXIF stripped, asks a vision model for the bilingual caption, and registers everything in photos.json — the map, the counts and the assistant's knowledge all follow from there/privacy| Command | Action |
|---|---|
npm run dev | Dev server at localhost:4321 (Astro 7 runs it as a daemon — stop with npx astro dev stop) |
npm run build | Production build to dist/ |
npm run preview | Serve the production build locally |
npm test | Unit tests for both hook pipelines (node --test, no extra dependencies) |
npm run ingest | Photo ingest by hand. Normally the pre-commit hook runs it; useful for watching what it does |
npm run sync | Bilingual sync by hand. Same — the hook runs it, this is for watching |
node scripts/check-links.mjs | Internal link integrity check over dist/ |
node scripts/verify-nav.mjs 等 | Playwright interaction suites (run against a local server) |
npm run dev (in worker/) | Chat + MCP Worker at localhost:8787 (wrangler; secrets in worker/.dev.vars, never committed) |
node scripts/verify-chat.mjs | E2E chat-widget test (needs both dev servers running) |
node scripts/verify-zoe.mjs | E2E for Zoe's action state machine (append ?zoe-fast locally to compress minute-scale timers) |
node scripts/verify-typeroute.mjs | E2E for the intent-driven typing clip and the bilingual 404 page |
Any Playwright suite that waits on Zoe's state must pin the clock (
Date.prototype.getHours = () => 14): between 23:00 and 06:00 she starts the session asleep, sostatenever reachesidleand the run just times out.
When adding a Zoe clip, decide who prewarms it and when at the same time. A clip that is only fetched at playback stalls on a slow connection, and the stage shows nothing until it decodes. Prewarming has been missed three times already. Note
warm()takes the file name (sit-to-loaf), not theZOEkey (sitToLoaf).
The chat panel is rebuilt on every navigation —
transition:persistkeeps Zoe's stage, not the panel. Anything that lives only in panel DOM is gone the moment a visitor clicks a link. The streaming reply, the guidance chip and the unsent draft each had to be given module state plus a path back throughpaint(); the chip was lost for weeks before anyone noticed. So when adding persistent UI here, answer two questions up front: how doespaint()rebuild it, and should it ride along insessionStoragewith the history? Measure geometry only once the panel is visible —scrollHeightis 0 while it is hidden, which silently writesheight: 0px.
Turnstile guards
/chatand/classify. It must never guard/mcp. That endpoint exists so machines can read Sigao's profile — it is in the official registry — and Turnstile exists to stop machines. It also costs nothing to serve: the tools read the knowledge pack and never call a model. The static outlets (llms.txt,knowledge.json,.well-known/mcp.json) are served by Pages and never reach the Worker at all.
Locally, Turnstile uses Cloudflare's always-pass test keys — sitekey in
site.tsbehindimport.meta.env.DEV, secret inworker/.dev.vars. The real key rejects headless browsers, which is exactly its job, so every suite that drives a real Worker would fail against it. The real secret exists only in production, set withwrangler secret put. A corollary worth remembering: the production happy path cannot be verified from a script — reaching it needs a human in a real browser. Automation can still prove the gate is up (a request with no credential must return 403).
One knowledge layer, three outlets: /llms-full.txt for passive crawlers, a chat assistant
(POST /chat, SSE) for humans, and an MCP server (/mcp, Streamable HTTP, no auth — tools:
get_profile / list_experience / get_case_study) for visiting agents, both served from
api.sigaoli.com (Cloudflare Worker, code in worker/). The knowledge pack
(/knowledge.json) is assembled at build time from the
same sources as the pages — persona markdown, cv.json, case studies, photo stats — so any
content edit propagates to all three outlets on the next deploy, no manual step. A privacy
guard fails the build if sensitive patterns (phone numbers, IDs, coordinates) ever leak into
the pack.
Alongside each reply the chat runs a lightweight intent classifier (POST /classify, a small
model) to suggest the single most relevant page, and can remember a returning visitor's name —
both kept entirely in the visitor's own browser (opt-in, clearable via "Forget me"), never on a
server. Visitors in the EU/EEA/UK have their chat and classification routed to an EU-hosted
provider, never the China-direct API. What the site stores and sends is described in plain
language at /privacy.
docs/bilingual-sync-design.md.src/data/cv.json or cv.zh.json — the hook keeps the other in step entry by
entry, so changing one role doesn't touch the rest. The timeline, /resume.json and
/llms-full.txt all render from it. Replace public/files/pdf/CV__Sigao_Li.pdf alongside.
(skills is deliberately left out of the sync: the two languages use different shapes there
and the renderer handles both.)src/lib/i18n.ts._inbox/ and commit. The pre-commit
hook files them by GPS, archives the originals to _originals/ (never committed), derives
2560px serving masters, writes bilingual captions with a vision model, and registers them in
src/data/photos.json. Photos without GPS go in _inbox/<country-id>/ instead. Counts,
the map and the AI knowledge pack all follow from photos.json automatically.
See docs/photo-ingest-design.md.src/data/knowledge/*.md; the knowledge pack rebuilds on
every deploy and the assistant follows within ~10 minutes (Worker-side cache TTL).scripts/zoe-board2.mjs → zoe-qc2.mjs → zoe-prod2.mjs) keys, QCs, mirrors and
encodes them into public/zoe/. New actions = one clip + one row in the ZOE table in
ChatWidget.astro; specs and prompt cards in docs/zoe-production-handbook.md.Two chains run from a pre-commit hook: photo ingest, and bilingual sync. After cloning, point git at the tracked hooks directory once:
Without this neither runs — photos dropped into _inbox/ stay there, and the two
languages stop tracking each other. Both exit immediately when there is nothing to
do, so an ordinary commit is unaffected.
Both need .env (copy .env.example): the model calls for captions, translation
and the staleness check all go through that gateway.
Regenerating the lock file. package-lock.json has to be built on Linux. npm
records only the optional dependencies it can resolve on the machine it runs on,
so a lock made on Windows omits what sharp needs on a runner and npm ci refuses
to install. After changing dependencies here, run the Relock on Linux workflow
from the Actions tab; it rebuilds the lock, proves npm ci works, and commits the
result. Ordinary pushes never need it — and if you forget, the verify job says so.
Push to master → deploy.yml builds and deploys to Pages. Pushes to v2 build
without deploying (verification).
Two more workflows watch rather than gate, each emailing on failure and neither able to hold up a release:
| Workflow | When | What |
|---|---|---|
verify.yml | every push | Builds, checks internal links, then drives eight browser scripts against the production build. Failures upload screenshots and Playwright traces — drop a trace into trace.playwright.dev to replay the run frame by frame |
audit.yml | Mondays | npm audit over the site and the worker |
The audit used to gate the build. It reads the day's advisory database rather than this repository, so an untouched commit could go from green to red overnight — which is exactly what stopped a photo batch from shipping on 13 September. Knowing is worth an email; being blocked is not.
The Worker deploys separately: cd worker && npx wrangler deploy (secrets via
wrangler secret put; custom domain api.sigaoli.com bound in the Cloudflare dashboard).
When a batch changes both, deploy the Worker first — the chat UI calls its endpoints, so a
site push ahead of the Worker leaves a brief window where those calls 404.
A deploy takes up to a minute to reach every edge location. Checking immediately reads the previous version, which has twice looked like a broken deploy when nothing was wrong — wait, then check.
Daily chat usage is at https://api.sigaoli.com/usage (last seven days, plus whether today has
hit the cap). The cap itself is DAILY_CAP in worker/src/core/quota.ts; when it trips it emails
once via Cloudflare Email Routing.
⚠️ Never click "Sync fork". This repository began as an academicpages fork; syncing would reset
masterto the upstream template. If that ever happens again:git push --force origin <good-commit>:master.