A terminal-styled personal site with a fully on-device AI chatbot.
No API keys. No server. No data leaves the browser.
$ ask
→ initializing on-device LLM...
→ ready. backend: Gemini Nano (Chrome Prompt API, on-device)
ask anything about my work, projects, or background.
commands: /exit · /clear · /model · /help
pranav-chat> what are some of your projects?
ai › [the model answers from the system prompt — no network call]
The ask command auto-detects the best available on-device backend:
| Tier | Backend | Requirements | Model size | Speed |
|---|---|---|---|---|
| 1 | Chrome Prompt API | Chrome 138+, flag enabled, desktop | ~4GB (once) | Fast |
| 2 | WebLLM + WebGPU | Any WebGPU browser, ≥4GB RAM | 800MB–2GB | 40–180 tok/s |
| 3 | Graceful refusal | Anything else (iOS Safari, Firefox, low-end devices) | — | — |
Tier 1 uses Google's LanguageModel JS API (currently behind
chrome://flags/#prompt-api-for-gemini-nano).
Tier 2 activates automatically when Nano isn't available and WebGPU is. The
model is chosen by available system RAM: Phi-3.5 mini on ≥8GB, Llama 3.2 1B
on 4–8GB. First use always shows a download-consent step before fetching any
weights. ask --webllm forces this path even when Nano would otherwise be used.
| UI | React 19 + TypeScript + Vite 8, React Compiler |
| Package manager | Bun |
| On-device LLM (fast path) | Chrome Prompt API — Gemini Nano |
| On-device LLM (fallback) | @mlc-ai/web-llm — WebGPU |
| Hosting | Cloudflare Workers (@cloudflare/vite-plugin + wrangler) |
| Linting | ESLint flat config + typescript-eslint + eslint-plugin-react-hooks |
bun install
bun run dev # http://localhost:5173
bun run build # tsc -b && vite build → dist/
bun run preview # build + wrangler dev (serves locally like prod)
bun run test
bun run lint
bun run deploy # build + wrangler deploy → Cloudflaresrc/
├── App.tsx # mounts <Terminal />
├── main.tsx # React root
├── index.css # global reset + font preload + anti-flash bg
├── types.ts # discriminated-union TerminalLine + CommandTable
├── commands/
│ ├── index.ts # assembles CommandTable from individual commands
│ ├── ask.ts # opens the on-device chat session
│ ├── theme.ts # switch color theme
│ └── ... # help, whoami, projects, skills, contact, etc.
├── components/
│ ├── Terminal.tsx # the shell — input, history, boot seq, chat mode
│ └── Line.tsx # pure renderer for every TerminalLine type
├── content/
│ ├── site.ts # static portfolio content (skills, projects, contacts)
│ └── system-prompt.ts # BIO + SYSTEM_PROMPT — the LLM's source of truth
├── lib/
│ ├── llm.ts # backend detection + cascading session factory
│ ├── completion.ts # tab-completion logic
│ ├── crawlable.ts # build-time SEO / JSON-LD generator (used by vite.config.ts)
│ └── levenshtein.ts # "did you mean?" for unknown commands
└── themes/
├── index.ts # assembles and exports all themes
├── contrast.ts # WCAG contrast checker
└── espresso|gruvbox|nord|paper|tokyo.ts # one file per theme
- Replace
src/content/site.tswith your own skills, projects, and contact links. - Rewrite
src/content/system-prompt.tswith your BIO and any rules for the model. - Update the
IDENTITYobject at the top ofsite.ts—crawlable.tsderives the page title, meta description, and JSON-LD Person from it automatically. - Swap or add themes in
src/themes/.
The LLM pipeline, command table, and terminal UI are persona-agnostic.
WebLLM's multi-threaded WASM runtime needs SharedArrayBuffer, which requires
cross-origin isolation. The dev server sets the headers in
vite.config.ts; for production they're in
public/_headers (Cloudflare Pages / Netlify format).
Deploying elsewhere? Add these to every response:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
- Gemini Nano is small — roughly autocomplete-class. Keep the BIO tight and factual; the system prompt's "I don't have that information" rule covers hallucinations.
- WebGPU isn't universal. Mobile Safari and most Android browsers hit the graceful-refusal tier.
- First WebLLM load is slow (800MB–2GB download). Service Worker caching makes subsequent visits instant.
- Prompt injection. User input is wrapped in
<user_question>tags with explicit anti-injection rules in the system prompt — defense-in-depth, not a guarantee. Blast radius is small (text generation only). - Chrome auto-updates Nano — the model can change without notice.
Everything currently fits in the system prompt. If the BIO grows past ~2000 tokens, switch to RAG:
- Chunk the BIO into paragraphs.
- Embed locally with Transformers.js (
Xenova/all-MiniLM-L6-v2). - On each query, retrieve top-3 chunks by cosine similarity.
- Inject only those chunks into the prompt.
MIT — feel free to learn from it, fork it, or use it as a starting point for your own terminal site. But don't redeploy it under my name.
