Compare commits

...

45 Commits

Author SHA1 Message Date
953b3efd58 Add Forrest Mims Mini-Notebooks resource link
Links the homepage reference row to forrest.warehack.ing/mims/ — the classic
hand-drawn Engineer's Mini-Notebook series. Custom spiral-notebook SVG with a
sketched resistor zigzag, in warm amber to sit apart from the teal IC chip.
2026-07-04 19:12:56 -06:00
afb8218b00 Fix oscilloscope tracks: transcode N-Spheres FLAC to MP3
The 5 N-Spheres tracks shipped as full-length FLAC (30-52MB each, ~189MB
total) and failed to play in Safari/iOS (flaky FLAC support). Transcode to
192kbps MP3 (~41MB total, stereo preserved for the XY Lissajous) which plays
everywhere. Matches the working spirals_shrt.mp3 pattern.
2026-07-04 19:12:56 -06:00
ad93d6cbbb Add audio pitch player to homepage hero
A 90-second spoken lightning talk (kokoro TTS) with a play/pause button and
progress bar in the hero CTA row. Registers audio-lines/play/pause icons.
2026-07-04 19:05:53 -06:00
49ee8d200b Note mcltspice cross-platform support (Linux & macOS) on homepage card 2026-07-03 23:43:12 -06:00
459a233511 Add restart: unless-stopped to backend and frontend
A frontend OOM during heavy LTspice image builds left it crashed with no
auto-recovery (Caddy served empty 200s). Restart policy self-heals crashes
and survives host reboots.
2026-06-20 22:37:37 -06:00
cc7874b0a3 Document LTspice debugging: ruled-out list for the headless-Wine spin 2026-06-20 22:33:58 -06:00
eb16dc307d Add gated LTspice engine via Wine 11.10
Wire mcltspice into the backend image: WineHQ 11.10 (matching the dev host),
i386 multiarch, Mesa software GL, a build-time Wine prefix seeded with the
LTspice.ini first-run config, and an entrypoint that starts Xvfb. The LTspice
install (exe/lib/examples) mounts from the host; the engine reads LTSPICE_DIR.

Gated for now: LTspice v26 stalls at graphics init under headless Wine in the
slim image (runs fine on a full desktop). The mount + LTSPICE_DIR are commented
in docker-compose.prod.yml so the engine fails fast as 'unavailable' rather than
hanging. ngspice is unaffected.
2026-06-20 07:43:08 -06:00
ea563ec997 Update prod IP and document git@ remote requirement 2026-06-20 07:43:08 -06:00
76c3d35894 Add iframe auto-resize via postMessage
Embeds now report content height to the parent frame using a
ResizeObserver on document.body. The EmbedDialog snippet includes
a listener script that adjusts the iframe height on each resize
event. Documented the spicebook-resize protocol in llms.txt.
2026-03-07 23:45:10 -07:00
d171352719 Add clock and layers icons to astro-icon include list
RecentNotebooks component uses these icons but they weren't in the
explicit include whitelist, causing a runtime AstroIconError.
2026-03-06 14:48:15 -07:00
8766069203 Add Recently Added notebooks section to homepage
Server-rendered section between Featured and Explore showing the 4
most recently modified notebooks, excluding featured and untitled ones.
Compact card layout with engine badge, cell count, and relative date.
2026-03-06 14:45:00 -07:00
63e9cd2ad2 Move docs link to hero CTA row for visibility
Docs link was buried in the reference links section. Promoted to
a hero-level button alongside New Notebook and Browse Notebooks,
with a blue hover accent to distinguish it.
2026-03-06 12:01:56 -07:00
844088aa29 Add Starlight docs site with API reference, guides, and embedding docs
Starlight site at docs-site/ following warehack.ing cookie-cutter pattern
(3-compose split, Caddy prod stage, Makefile targets). 15 content pages
covering simulation engines, waveform viewer, schematics, embedding with
postMessage theme sync, and full REST API reference adapted from llms.txt.
Blue accent theme matching SpiceBook's application palette. Adds docs link
to homepage reference section.
2026-03-06 11:55:21 -07:00
e25a661071 Update homepage copy to mention LTspice alongside ngspice 2026-03-05 22:31:31 -07:00
1f6c0c3735 Handle corrupt/empty notebook files, atomic writes
_read_notebook_file returns None on empty or unparseable JSON instead
of crashing with JSONDecodeError (surfaced as 500). save_notebook now
writes to a temp file and atomically renames, preventing empty files
from container restarts mid-write.
2026-03-05 19:33:44 -07:00
dc987bd0e4 Replace hardcoded slate-* classes with theme-aware sb-* utilities
WaveformViewer, SchematicViewer, and SimulationLog now use CSS
variable-backed utility classes (bg-sb-surface, text-sb-muted,
border-sb-border, etc.) instead of hardcoded Tailwind slate colors.
Light mode embed theme now works through the variable system rather
than brute-force class overrides in embed-theme.css.
2026-03-05 19:26:23 -07:00
acd5abd12e Add embedding docs and LTspice engine to llms.txt 2026-03-05 15:42:56 -07:00
69adfc5683 Open embed route to all origins, add embed snippet UI, enable LTspice
frame-ancestors * for /embed/* routes so any site can iframe notebooks.
Remove postMessage origin allowlist (theme toggle is cosmetic-only).
Add EmbedDialog popover with copy-paste iframe snippet and theme picker.
Enable ltspice in the engine dropdown now that the backend supports it.
2026-03-05 15:41:51 -07:00
165767c3d3 Add LTspice simulation engine via mcltspice
Wire up LTspice as a second simulation engine using mcltspice (Wine-based
LTspice runner). The backend architecture already had the ABC + factory
pattern; this connects the ltspice branch.

- Extract shared raw_to_waveform() into raw_convert.py with Protocol typing
  so both ngspice and mcltspice RawFile objects work without coupling
- Add LtspiceEngine with deferred mcltspice import for graceful degradation
- Register "ltspice" in get_engine() with availability check
  (mcltspice + Wine + LTspice.exe must all be present)
- Add startup validation log for LTspice availability
- Add mcltspice as optional dependency: pip install spicebook[ltspice]
- Integration tests auto-skip when LTspice is unavailable (Docker/CI)
2026-03-05 15:06:41 -07:00
2fddfa54a3 Use component-aware sizing for schematic element lengths
Inductors, capacitors, and diodes use compact length (3.0) while
resistors keep full length (4.0) for readable color-coded bands.
Reduces vertical stretch in inductor-heavy circuits like Hartley.
2026-03-02 02:10:57 -07:00
132cefee81 Fix title parser and feedback component routing in schematics
Title parser: always use line 0 as title per SPICE convention instead
of skipping * comment lines (which consumed component definitions).

Layout engine: pass growing placed set to path tracing to prevent
feedback components (e.g. col→base cap) from being double-traced.
Route feedback paths from the output terminal horizontally right
instead of defaulting to "down" through the transistor body.
2026-03-01 23:30:57 -07:00
3eb8f3794b Add fullscreen expansion to schematic and waveform viewers
Repurpose the Maximize2 button in SchematicViewer as a fullscreen
toggle (was reset-zoom; percentage label is now clickable for that).
Add matching fullscreen toggle to WaveformViewer alongside the scope
button. Both use a shared useFullscreen hook for Escape-to-exit and
body scroll lock. In fullscreen mode, the SVG canvas and plot areas
fill the viewport with proper dimension tracking via ResizeObserver.
2026-02-24 14:15:57 -07:00
b1c9534d55 Fix PNP wire-through-body routing and inductor coil fill
The is_inverted flag incorrectly reversed stub wire directions for
PNP/PFET devices, causing wires to route through the transistor body
instead of away from it.  SchemDraw's BjtPnp already orients the
emitter (supply) terminal at the top, so stubs should always go UP
for supply and DOWN for ground regardless of polarity.

- Remove is_inverted direction flipping from _path_style and stubs
- Use output_term (always collector/drain) for output path detection
  instead of supply_term which varies by polarity
- Add .fill(_GRID_BG) to Inductor2 elements so the coil body has a
  solid background fill, visually breaking the through-wire
2026-02-24 13:28:59 -07:00
9d6590b59d Increase component clearance and add text halo for label visibility
- _LEAD_STUB_LENGTH 1.0 → 2.0: prevents inductor/component bodies from
  overlapping the transistor symbol on complex circuits (Hartley, Colpitts)
- _DEVICE_LABEL_OFST 0.4 → 0.6: pushes Q1 label further from junction
  wires, especially important for PNP layouts
- Add _add_text_halos() SVG post-processor: applies paint-order stroke
  with white halo behind all text so net labels (tap, in, out) remain
  readable when wires cross underneath
2026-02-24 13:03:03 -07:00
65f7db3dc5 Fix vertical component labels using perpendicular SchemDraw loc values
SchemDraw's loc="left"/"right" on vertical elements places labels at the
element start/end (along the axis), not perpendicular to the body. Use
loc="top" for geometric-left and loc="bottom" for geometric-right to
position labels beside the component body instead of above/below it.

Affects _draw_vert_chain, _render_loop, and _draw_horiz_then_down.
2026-02-24 09:02:23 -07:00
efd5977d9b Merge feature/schematic-label-readability: improve label readability and eliminate wire crossings 2026-02-23 21:29:01 -07:00
d380eb561f Improve schematic label readability and eliminate wire crossings
Replace scattered magic numbers with named layout constants (_LABEL_OFST,
_NET_LABEL_FONTSIZE, _PARALLEL_PATH_SPACING, etc.) for consistent spacing.

Add "input_up" path classification that routes base/gate bias paths to
VCC left-then-up with local Vdd symbols, preventing wire crossings with
collector/drain vertical paths. Add _choose_label_side() for smart
alternating label placement and _sort_parallel_paths() to draw longest
chains closest to the device body.

Fix PNP/PFET polarity handling: supply terminal stub direction now
matches device polarity instead of always drawing upward.

Vertical chain label improvements:
- valign='bottom' for down-going chains pushes labels above midpoint
- Gap wire (0.5 units) before ground/Vdd terminators prevents overlap
- Minimum component length (_VERT_CHAIN_MIN_LEN) for readable labels
- valign='center' on device body and horizontal-turn labels
2026-02-23 20:51:07 -07:00
d0b03507ad Add standalone /chat page with full-page conversation UI
Extract shared rendering (chat-render.ts) and streaming hook
(use-chat-stream.ts) from ChatWidget so both the floating panel
and the new page share identical markdown/KaTeX/SSE logic.

New page features:
- Responsive sidebar (inline desktop, Sheet drawer mobile)
- Conversation search/filter
- Notebook picker via cmdk command palette
- Auto-growing multi-line textarea input
- Pop-out button on widget header to open /chat

ChatLayout.astro omits the floating widget to avoid duplicate UI.
Chat store gains selectedNotebookId for page-level notebook context.
shadcn-ui primitives (ScrollArea, Sheet, Command, Popover, etc.)
wired to existing SpiceBook dark theme tokens.
2026-02-23 18:54:34 -07:00
1e6dd909c5 Fix chat panel scroll: replace scrollIntoView with container.scrollTo
scrollIntoView walks up the DOM and scrolls every ancestor, including
the panel with overflow:hidden — this pushed the header and messages
area off-screen after long LLM responses. Using container.scrollTo
limits scrolling to only the messages div.
2026-02-23 14:56:41 -07:00
5fd0fd3b29 Add MCP chat reference architecture documentation
Comprehensive guide for building MCP-powered chat assistants with SSE
streaming, covering both Hamilton Archive (vanilla JS) and SpiceBook
(React + Zustand) implementations. Includes Caddy routing patterns,
security hardening checklist, and frontend lessons learned.
2026-02-23 14:26:05 -07:00
c73412e139 Update docker-compose and astro config for chat/MCP integration
- Add ANTHROPIC_API_KEY env passthrough in docker-compose.yml
- Add astro telemetry and devToolbar disable settings
- Update CLAUDE.md with deployment notes
2026-02-23 14:25:58 -07:00
cf2b18ea2f Consistent label offsets across all schematic layout engines
Add ofst=0.15 to all component labels for uniform clearance between
label text and component bodies. Previously, loop layout had no offsets,
grid layout was missing offsets on multi-terminal devices (BJT/FET), and
connected layout was missing offsets on transistor and horizontal labels.
2026-02-23 14:25:50 -07:00
dca53328bc Chat widget: markdown styling, KaTeX math rendering, SSE streaming fix
- Add comprehensive CSS for markdown elements in chat bubbles (headers,
  lists, tables, blockquotes, links, code blocks, horizontal rules)
- Integrate KaTeX via marked-katex-extension for inline ($) and display
  ($$) math rendering with DOMPurify whitelist for MathML/SVG elements
- Add normalizeDisplayMath() pre-processor to handle LLM output where $$
  delimiters appear inline rather than on their own lines
- Add dark theme KaTeX styles matching the SpiceBook color palette
- Fix asyncio.to_thread usage in chat SSE endpoint for streaming
2026-02-23 14:25:42 -07:00
1242683a21 Add MCP server and chat assistant
- Mount FastMCP at /mcp with tools for notebook CRUD, simulation,
  and cell execution. Includes status resource and circuit_assistant
  prompt.
- Add SSE streaming chat endpoint at /api/chat/stream backed by
  GPU LLM gateway (qwen3). Chat widget sends notebook context
  (SPICE cells, markdown notes) so the assistant can reference the
  user's circuit.
- React floating chat panel with zustand-persisted conversation
  history, streaming token display, reasoning collapse, and
  keyboard shortcuts.
- Refactor main.py from deprecated on_event("startup") to lifespan
  context manager with combine_lifespans for MCP integration.
- Add notebook_id path traversal validation, decouple get_engine()
  from HTTPException for MCP compatibility, fix HTTP client init
  race condition with asyncio.Lock.
- Update Caddy labels for /mcp/* routing and SSE streaming on
  backend reverse proxy.
2026-02-22 16:49:15 -07:00
7e841b7c13 Visual polish: card hover lift, gradient section dividers, editor spacing
- Cards (featured + gallery): hover adds -translate-y-1 + shadow-lg for
  subtle lift-off-surface effect
- Homepage sections: replace hard border-b with gradient-fade dividers
  that taper to transparent at edges
- Resistor button section: tighten vertical padding (py-10 → py-6)
- Notebook editor: cell spacing increased (space-y-0 → space-y-3),
  add-cell button slides up on hover instead of abrupt opacity flash,
  status bar gets more breathing room and lighter text
2026-02-21 15:28:08 -07:00
cb6171bd70 Add chevron-right to astro-icon include list
The 50Ω resistor button uses lucide:chevron-right which wasn't in the
explicit icon include list, causing SSR to crash mid-stream on the homepage.
2026-02-21 11:35:09 -07:00
e4df6babc0 Move resistor color guide to /reference/resistor-colors, add 50Ω button on homepage
Promotes the full ResistorColorGuide component to a dedicated reference
page and replaces the homepage section with a compact inline SVG link
showing Green-Black-Black-Gold (50Ω ±5%) band colors.
2026-02-21 11:32:16 -07:00
76fd811071 Add flush_interval to prod frontend Caddy labels
Without flush_interval: -1, Caddy buffers SSR responses and can
truncate large pages (380KB homepage was cut to 70KB, dropping the
ResistorColorGuide section entirely).
2026-02-21 10:05:34 -07:00
0fd327ef85 Fix dev backend startup: use python -m uvicorn instead of uv run
uv pip install --system installs packages globally but uv run still
tries to create /app/.venv, which fails with permission denied.
Add PYTHONPATH=/app/src since the editable install .pth file is empty
when built without source directory present.
2026-02-21 09:17:03 -07:00
7e1ebecaa7 Add schematic thumbnails, descriptions to notebook cards and resistor color guide
Notebook cards now show schematic SVG thumbnails (on Mims green background)
and description snippets extracted from the first markdown cell. The backend
extracts both from already-loaded notebook data at zero additional I/O cost.

New ResistorColorGuide section between the pipeline strip and featured
notebooks explains the 4-band color code with an annotated zigzag diagram
and reference table using the same BAND_HEX colors from the schematic renderer.

Also switches docker-compose.dev.yml from direct port mapping to Caddy labels,
matching the production routing pattern and avoiding port conflicts.
2026-02-21 08:56:48 -07:00
dbe195d9fb Add schematic pipeline step and fix vertical label spacing
Add ofst=0.15 to vertical resistor label calls preventing overlap with
color-coded zigzag bands. Add Schematic step 03 to homepage pipeline
with inline SVG voltage divider on Mims graph paper. Pipeline now
4-step with responsive 1col/2x2/4col layout.
2026-02-20 20:34:41 -07:00
751a30ac51 Merge feature/color-coded-resistors: color-coded zigzag resistor symbols 2026-02-20 17:37:01 -07:00
66a521b2b8 Color-code resistor zigzag symbols with standard 4-band colors
Map physical resistor color bands onto the IEEE zigzag schematic symbol.
Each sub-segment is colored to match its band (digit, multiplier, tolerance),
with wire-colored entry/exit and a gap before tolerance mimicking real spacing.
Parseable values 0.01–1GΩ get color; parametric/out-of-range fall back to mono.
2026-02-20 17:36:50 -07:00
89bfa8c5a5 Fix oscilloscope CSS: use is:global for child component styles
Astro scopes <style> block imports to the authoring component.
Since OscilloscopeDisplay.astro is a child of index.astro, the
scoped selectors didn't match. Move the CSS import into the
component itself with is:global.
2026-02-20 16:31:20 -07:00
a4040ca5fe Replace static hero scope with interactive XY-mode oscilloscope
Port the OscilloscopeDisplay from mcltspice docs — Web Audio API
renders stereo audio as Lissajous patterns on a canvas with teal
phosphor persistence. 6 tracks from Jerobeam Fenderson, volume
knob, skin switcher (465/545A), and the Outer Limits easter egg.

Audio: CC BY-NC-SA 4.0, oscilloscopemusic.com
Visual: Nick Watton (codepen.io/2Mogs), rsp2k
2026-02-20 16:25:13 -07:00
119 changed files with 19152 additions and 737 deletions

3
.gitignore vendored
View File

@ -41,6 +41,9 @@ docker-compose.override.yml
# Notebook user data (tracked separately)
notebooks/user/
# LTspice install -- transferred to prod host via rsync, not tracked (~182MB)
/ltspice/
# Coverage
htmlcov/
.coverage

View File

@ -12,17 +12,26 @@ Notebook interface for SPICE circuit simulation.
```bash
# Local dev (Docker)
make dev
# Frontend: http://localhost:4321
# Backend: http://localhost:8099
# Local site: https://spicebook.l.warehack.ing (via caddy-docker-proxy)
# API docs: https://spicebook.l.warehack.ing/docs
# Local dev (no Docker)
cd backend && uv run uvicorn spicebook.main:app --host 0.0.0.0 --port 8099 --reload
cd frontend && npm run dev -- --host 0.0.0.0 --port 4322
```
### Local vs Production domains
| Environment | Domain | `.env` setting |
|-------------|--------|----------------|
| Local dev | `spicebook.l.warehack.ing` | `SPICEBOOK_DOMAIN=spicebook.l.warehack.ing` |
| Production | `spicebook.warehack.ing` | `SPICEBOOK_DOMAIN=spicebook.warehack.ing` |
The `.env` on the dev machine should use `spicebook.l.warehack.ing` — the `.l.` subdomain resolves locally. Using the production domain (`spicebook.warehack.ing`) will route requests to the remote production server, not the local containers.
## Deployment to Production
Production runs on `warehack.ing` (149.28.126.25) behind caddy-docker-proxy.
Production runs on `warehack.ing` (64.177.113.31) behind caddy-docker-proxy.
```bash
# 1. Push changes
@ -43,6 +52,7 @@ docker compose -f docker-compose.yml -f docker-compose.prod.yml logs --tail=20
### Production environment notes
- `.env` on the production server is NOT tracked in git — edit it directly on the server when adding new env vars
- The prod checkout's git remote must use the `git@` user: `git@git.supported.systems:warehack.ing/spicebook.git`. The scp-style `host:path` form defaults to the server's login user (`warehack-ing`) and gitea rejects it with `Permission denied (publickey)`. `ssh -A` forwards your local key through for the fetch.
- `BACKEND_INTERNAL_URL=http://backend:8000` must be set for SSR server-side fetches (frontend talks to backend over Docker network)
- `notebooks/user/` must be world-writable (chmod 777) because the backend runs as non-root user `spicebook` but the bind mount is owned by `warehack-ing`
- The `@resvg/resvg-js` native binary requires `node:20-slim` (Debian glibc) — Alpine won't work

View File

@ -38,6 +38,7 @@ SpiceBook lets you write a document that says: "here's why this filter works, he
- Click-drag zoom, scroll to pan
- **ngspice backend** -- subprocess execution with binary `.raw` file parsing
- **Notebook operations** -- create, open, edit, save, delete, reorder cells, run individually or all at once
- **Color-coded resistor schematics** -- zigzag symbols colored with standard 4-band resistor color codes. A 10kΩ resistor renders Brown-Black-Orange-Gold directly on the schematic symbol, bridging the gap between physical components and abstract schematics. Falls back to standard monochrome for parametric or out-of-range values.
- **Keyboard shortcuts** -- `Ctrl+S` save, `Shift+Enter` run cell
- **Clean URLs** -- `/notebook/rc-lowpass-filter` via Astro dynamic routes
- **15 example notebooks** included:
@ -139,7 +140,9 @@ spicebook/
│ │ ├── config.py # Settings from environment
│ │ ├── engine/ # SPICE engine abstraction
│ │ │ ├── base.py # Abstract base class
│ │ │ └── ngspice.py # ngspice subprocess + .raw parser
│ │ │ ├── ngspice.py # ngspice subprocess + .raw parser
│ │ │ ├── schematic.py # Netlist → SVG schematic (SchemDraw)
│ │ │ └── resistor_colors.py # Color-coded zigzag resistors
│ │ ├── models/ # Pydantic schemas
│ │ │ ├── notebook.py # Notebook, Cell, CellOutput
│ │ │ └── simulation.py

View File

@ -1,8 +1,43 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS base
# System dependencies -- ngspice is required for simulation
# System dependencies:
# ngspice -- native SPICE engine
# wine -- runs LTspice (a Windows x86-64 binary) in batch mode
# xvfb -- headless X display; LTspice opens an X connection even with -b
# The LTspice binaries themselves are NOT in the image -- they are mounted from
# the host at /opt/ltspice-src (see docker-compose.prod.yml). Without that mount
# the LTspice engine degrades gracefully and ngspice still works.
# Debian's Wine (8.0) is too old for LTspice v26 -- it opens a GUI event loop
# instead of running -b headless. Use WineHQ 11.10 to match the dev host, where
# LTspice runs fine. i386 multiarch is required (Wine pulls 32-bit components
# even for the x86-64 LTspice.exe). All four packages are pinned to the same
# version because the WineHQ repo sometimes carries mismatched amd64/i386
# versions, and the wine-devel metapackage demands an exact-version match.
RUN dpkg --add-architecture i386 && \
apt-get update && \
apt-get install -y --no-install-recommends \
ngspice xvfb xauth wget gnupg ca-certificates && \
mkdir -pm755 /etc/apt/keyrings && \
wget -qO- https://dl.winehq.org/wine-builds/winehq.key | gpg --dearmor -o /etc/apt/keyrings/winehq-archive.key && \
wget -qNP /etc/apt/sources.list.d/ https://dl.winehq.org/wine-builds/debian/dists/bookworm/winehq-bookworm.sources && \
apt-get update && \
apt-get install -y --install-recommends \
winehq-devel=11.10~bookworm-1 wine-devel=11.10~bookworm-1 \
wine-devel-amd64=11.10~bookworm-1 wine-devel-i386=11.10~bookworm-1 && \
rm -rf /var/lib/apt/lists/*
# WineHQ installs to /opt/wine-devel/bin; put it on PATH so `wine` resolves
# (mcltspice's runner invokes the bare `wine` command).
ENV PATH="/opt/wine-devel/bin:${PATH}"
# LTspice v26 blocks on graphics init unless libGL/libEGL are loadable. The
# slim image ships neither, so add Mesa software rendering (llvmpipe) -- Xvfb
# has no GPU, so this is the fallback the dev host reaches via DRI3 anyway.
# Separate layer to keep the heavy WineHQ layer cached.
RUN apt-get update && \
apt-get install -y --no-install-recommends ngspice && \
apt-get install -y --no-install-recommends \
libgl1 libglx-mesa0 libgl1-mesa-dri libegl1 libegl-mesa0 \
libvulkan1 mesa-vulkan-drivers && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
@ -18,7 +53,7 @@ FROM base AS dev
# Install in editable mode (source mounted via docker-compose volume)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv pip install --system -e ".[dev]"
uv pip install --system -e ".[dev,ltspice]"
# Copy source for initial build (overridden by volume mount in dev)
COPY src/ ./src/
@ -37,20 +72,50 @@ ENV UV_COMPILE_BYTECODE=1
# Install dependencies first (no source yet -- better caching)
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv pip install --system .
uv pip install --system ".[ltspice]"
COPY src/ ./src/
# Re-install with source to get the package registered
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --system .
uv pip install --system ".[ltspice]"
# Run as non-root
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Run as non-root. /opt/ltspice is the writable LTSPICE_DIR (holds the Wine
# prefix); /opt/ltspice-src is the read-only mountpoint for the host binaries.
RUN useradd --create-home --shell /bin/bash spicebook && \
mkdir -p /app/notebooks/user /app/notebooks/examples && \
chown -R spicebook:spicebook /app/notebooks
mkdir -p /app/notebooks/user /app/notebooks/examples /opt/ltspice /opt/ltspice-src && \
chown -R spicebook:spicebook /app/notebooks /opt/ltspice
USER spicebook
# Build a fresh Wine prefix with this image's own Wine version (avoids the
# version-mismatch hazard of shipping a prefix built by a different Wine).
# LTspice batch mode needs no Mono/Gecko, so disable them to skip the download.
ENV WINEARCH=win64 \
WINEPREFIX=/opt/ltspice/.wine \
WINEDEBUG=-all \
WINEDLLOVERRIDES="mscoree=;mshtml="
# Keep one Xvfb up for the whole init -- xvfb-run tears the display down the
# moment wineboot returns, leaving wineserver's lingering processes to spin on
# a dead X server (fatal XIO errors, hung build). Explicit display + timeouts
# make the step deterministic and unable to hang.
RUN Xvfb :99 -screen 0 1024x768x16 -nolisten tcp >/tmp/xvfb-build.log 2>&1 & \
sleep 2 && \
DISPLAY=:99 timeout 90 wineboot --init && \
DISPLAY=:99 timeout 30 wineserver -w; \
wineserver -k 2>/dev/null; pkill Xvfb 2>/dev/null; \
rm -f /tmp/.X99-lock /tmp/.X11-unix/X99; true
# Seed LTspice's settings file so batch (-b) runs skip the one-time first-run
# consent dialog (which otherwise blocks even in batch mode and the sim hangs).
# Captured from a broken-in install; LastRunVersion/CaptureAnalytics are the
# lines that matter. Without this the LTspice engine times out on every run.
COPY --chown=spicebook:spicebook ltspice-config/LTspice.ini \
/opt/ltspice/.wine/drive_c/users/spicebook/AppData/Roaming/LTspice.ini
EXPOSE 8000
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["python", "-m", "uvicorn", "spicebook.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -0,0 +1,34 @@
#!/bin/bash
# Wire the host-mounted LTspice binaries into the writable LTSPICE_DIR (which
# holds the Wine prefix baked at image build), start a virtual display for
# Wine, then hand off to the CMD.
set -e
src=/opt/ltspice-src
dst=${LTSPICE_DIR:-/opt/ltspice}
# The LTspice install is mounted read-only from the host; symlink the three
# things mcltspice's config references (exe, lib, examples) next to the prefix.
if [ -d "$src" ]; then
for item in LTspice.exe lib examples; do
if [ -e "$src/$item" ]; then
ln -sfn "$src/$item" "$dst/$item"
fi
done
fi
# LTspice opens an X connection even in batch (-b) mode, so give Wine a
# headless display. Clear any stale lock first -- the build-time Xvfb on the
# same display can leave /tmp/.X99-lock baked into the image, which would make
# a naive "already running?" check skip startup (one container = one Xvfb).
if [ -n "$DISPLAY" ]; then
rm -f "/tmp/.X${DISPLAY#:}-lock"
Xvfb "$DISPLAY" -screen 0 1024x768x16 -nolisten tcp >/tmp/xvfb.log 2>&1 &
# Give the server a moment to come up before the app can ask for it.
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -e "/tmp/.X11-unix/X${DISPLAY#:}" ] && break
sleep 0.3
done
fi
exec "$@"

Binary file not shown.

View File

@ -12,9 +12,12 @@ dependencies = [
"numpy>=1.24.0",
"websockets>=12.0",
"schemdraw>=0.19",
"fastmcp>=3.0.0",
"httpx>=0.28.0",
]
[project.optional-dependencies]
ltspice = ["mcltspice>=2026.02.14"]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",

View File

View File

@ -0,0 +1,149 @@
"""LLM client for chat completions via the GPU gateway."""
import asyncio
import collections.abc
import json
import logging
import httpx
from spicebook.config import settings
logger = logging.getLogger("spicebook.chat")
SYSTEM_PROMPT = (
"/no_think\n"
"You are a circuit simulation assistant integrated into SpiceBook, "
"a notebook environment for SPICE circuit design and simulation. "
"Help users understand circuits, debug netlists, interpret simulation "
"results, and design new circuits. When the user is viewing a notebook, "
"you will receive its SPICE cells and notes as context. "
"Be precise and practical — reference specific components, nodes, "
"and values from their circuit when available. "
"Format responses with markdown for readability."
)
# Module-level client reuses TCP/TLS connections across requests.
# Lock prevents race condition on lazy initialization.
_chat_client: httpx.AsyncClient | None = None
_client_lock = asyncio.Lock()
async def _get_chat_client() -> httpx.AsyncClient:
global _chat_client
if _chat_client is not None and not _chat_client.is_closed:
return _chat_client
async with _client_lock:
# Double-check after acquiring lock
if _chat_client is not None and not _chat_client.is_closed:
return _chat_client
_chat_client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=10.0,
read=120.0,
write=10.0,
pool=10.0,
),
limits=httpx.Limits(
max_connections=20,
max_keepalive_connections=10,
),
)
return _chat_client
async def close_client() -> None:
"""Close the HTTP client. Called during app shutdown."""
global _chat_client
async with _client_lock:
if _chat_client is not None and not _chat_client.is_closed:
await _chat_client.aclose()
_chat_client = None
async def chat_completion_stream(
context: str, question: str
) -> collections.abc.AsyncIterator[tuple[str, str]]:
"""Stream chat completion tokens from the GPU gateway.
Yields (kind, text) tuples where kind is 'content' or 'reasoning'.
"""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
]
if context:
messages.append({
"role": "user",
"content": f"Current notebook context:\n\n{context}\n\n---\n\nQuestion: {question}",
})
else:
messages.append({"role": "user", "content": question})
client = await _get_chat_client()
logger.debug(
"Chat stream: model=%s max_tokens=%d context_len=%d",
settings.gpu_chat_model, settings.chat_max_tokens, len(context),
)
async with client.stream(
"POST",
f"{settings.gpu_base_url}/chat/completions",
headers={
"Authorization": f"Bearer {settings.gpu_api_key}",
"Content-Type": "application/json",
},
json={
"model": settings.gpu_chat_model,
"messages": messages,
"temperature": 0.3,
"max_tokens": settings.chat_max_tokens,
"num_ctx": settings.chat_max_tokens,
"stream": True,
},
) as resp:
if resp.status_code >= 400:
body = await resp.aread()
error_text = body[:500].decode("utf-8", errors="replace")
logger.error("LLM gateway returned %d: %s", resp.status_code, error_text)
raise httpx.HTTPStatusError(
f"LLM gateway error {resp.status_code}",
request=resp.request,
response=resp,
)
reasoning_count = 0
content_count = 0
try:
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
payload = line[6:]
if payload.strip() == "[DONE]":
logger.debug(
"Chat stream done: %d reasoning chunks, %d content chunks",
reasoning_count, content_count,
)
return
try:
chunk = json.loads(payload)
delta = chunk["choices"][0]["delta"]
content = delta.get("content")
if content:
content_count += 1
yield ("content", content)
elif delta.get("reasoning_content"):
reasoning_count += 1
yield ("reasoning", delta["reasoning_content"])
finish = chunk["choices"][0].get("finish_reason")
if finish == "length":
logger.warning(
"Chat stream truncated (finish_reason=length) "
"after %d reasoning + %d content chunks",
reasoning_count, content_count,
)
except (json.JSONDecodeError, KeyError, IndexError):
logger.debug("Unparseable SSE chunk: %s", payload[:200])
continue
except GeneratorExit:
logger.debug("Chat stream cancelled after %d content chunks", content_count)
await resp.aclose()
return

View File

@ -16,6 +16,14 @@ class Settings:
self.backend_host: str = os.environ.get("BACKEND_HOST", "0.0.0.0")
self.backend_port: int = int(os.environ.get("BACKEND_PORT", "8000"))
# GPU LLM gateway (chat)
self.gpu_api_key: str = os.environ.get("GPU_API_KEY", "")
self.gpu_base_url: str = os.environ.get(
"GPU_BASE_URL", "https://spicebook.gpu.supported.systems/v1"
)
self.gpu_chat_model: str = os.environ.get("GPU_CHAT_MODEL", "qwen3")
self.chat_max_tokens: int = int(os.environ.get("CHAT_MAX_TOKENS", "8192"))
@property
def examples_dir(self) -> Path:
return self.notebook_dir / "examples"

View File

@ -1,13 +1,45 @@
"""SPICE simulation engine registry."""
from fastapi import HTTPException
import logging
from spicebook.engine.base import SpiceEngine
from spicebook.engine.ngspice import NgspiceEngine
logger = logging.getLogger(__name__)
class UnsupportedEngineError(ValueError):
"""Raised when an unknown engine name is requested."""
def _ltspice_available() -> bool:
"""Check whether mcltspice + Wine + LTspice.exe are all present."""
try:
from mcltspice.config import validate_installation
ok, msg = validate_installation()
if not ok:
logger.debug("LTspice not available: %s", msg)
return ok
except ImportError:
return False
except Exception as exc:
logger.warning("LTspice availability check failed: %s", exc)
return False
def get_engine(engine_name: str) -> SpiceEngine:
"""Resolve a simulation engine by name."""
if engine_name == "ngspice":
return NgspiceEngine()
raise HTTPException(status_code=400, detail=f"Unsupported engine: '{engine_name}'")
if engine_name == "ltspice":
if not _ltspice_available():
raise UnsupportedEngineError(
"LTspice engine is not available (requires mcltspice + Wine + LTspice.exe)"
)
from spicebook.engine.ltspice import LtspiceEngine
return LtspiceEngine()
raise UnsupportedEngineError(f"Unsupported engine: '{engine_name}'")

View File

@ -0,0 +1,98 @@
"""LTspice simulation engine -- delegates to mcltspice via Wine."""
import logging
import time
from pathlib import Path
from spicebook.engine.base import SpiceEngine
from spicebook.engine.raw_convert import raw_to_waveform
from spicebook.models.simulation import SimulationResponse
logger = logging.getLogger(__name__)
# Wine overhead means LTspice needs more headroom than native ngspice
LTSPICE_TIMEOUT_SECONDS = 120
class LtspiceEngine(SpiceEngine):
"""Run simulations via LTspice through Wine using mcltspice."""
async def run(self, netlist: str, work_dir: Path) -> SimulationResponse:
t0 = time.monotonic()
# Deferred import so the module loads even without mcltspice installed
try:
from mcltspice.runner import run_netlist
except ImportError as exc:
logger.error("mcltspice.runner import failed: %s", exc)
return SimulationResponse(
success=False,
error=f"mcltspice runner unavailable: {exc}",
elapsed_seconds=time.monotonic() - t0,
)
# Ensure netlist ends with .end
netlist_stripped = netlist.rstrip()
if not netlist_stripped.lower().endswith(".end"):
netlist_stripped += "\n.end\n"
else:
netlist_stripped += "\n"
cir_path = work_dir / "input.cir"
try:
cir_path.write_text(netlist_stripped)
except OSError as exc:
return SimulationResponse(
success=False,
error=f"Failed to write netlist to disk: {exc}",
elapsed_seconds=time.monotonic() - t0,
)
try:
result = await run_netlist(
netlist_path=cir_path,
timeout=LTSPICE_TIMEOUT_SECONDS,
)
except Exception:
logger.exception("LTspice execution failed unexpectedly")
return SimulationResponse(
success=False,
error="LTspice execution failed (see server logs)",
elapsed_seconds=time.monotonic() - t0,
)
log_text = result.stdout + result.stderr
if not result.success:
return SimulationResponse(
success=False,
log=log_text,
error=result.error or "LTspice simulation failed",
elapsed_seconds=result.elapsed_seconds,
)
if result.raw_data is None:
return SimulationResponse(
success=False,
log=log_text,
error=result.error or "No waveform data produced",
elapsed_seconds=result.elapsed_seconds,
)
try:
waveform = raw_to_waveform(result.raw_data)
except Exception:
logger.exception("Failed to convert LTspice raw data to waveform")
return SimulationResponse(
success=False,
log=log_text,
error="Failed to parse LTspice output (see server logs)",
elapsed_seconds=result.elapsed_seconds,
)
return SimulationResponse(
success=True,
waveform=waveform,
log=log_text,
elapsed_seconds=result.elapsed_seconds,
)

View File

@ -11,11 +11,8 @@ import numpy as np
from spicebook.config import settings
from spicebook.engine.base import SpiceEngine
from spicebook.models.simulation import (
SimulationResponse,
WaveformData,
WaveformVariable,
)
from spicebook.engine.raw_convert import raw_to_waveform
from spicebook.models.simulation import SimulationResponse
logger = logging.getLogger(__name__)
@ -300,7 +297,7 @@ class NgspiceEngine(SpiceEngine):
elapsed_seconds=time.monotonic() - t0,
)
waveform = _raw_to_waveform(raw)
waveform = raw_to_waveform(raw)
return SimulationResponse(
success=True,
@ -318,64 +315,3 @@ def _extract_error_lines(log: str) -> str:
if "error" in lower or "fatal" in lower or "cannot" in lower:
error_lines.append(line.strip())
return "\n".join(error_lines) if error_lines else ""
def _raw_to_waveform(raw: RawFile) -> WaveformData:
"""Convert parsed RawFile into a WaveformData response model."""
is_complex = "complex" in raw.flags
# Determine x-axis variable (first variable is always the sweep)
x_var = raw.variables[0]
x_type = "frequency" if "frequency" in x_var.type.lower() else "time"
# Build variable list (skip x-axis variable)
variables = []
for v in raw.variables[1:]:
variables.append(WaveformVariable(name=v.name, type=v.type))
# Convert x data
x_array = raw.data[0]
if is_complex:
x_data = np.real(x_array).tolist()
else:
x_data = x_array.tolist()
# Convert y data
y_data: dict[str, list[float]] = {}
y_magnitude_db: dict[str, list[float]] | None = None
y_phase_deg: dict[str, list[float]] | None = None
if is_complex:
y_magnitude_db = {}
y_phase_deg = {}
for v in raw.variables[1:]:
arr = raw.data[v.index]
# Real part as the default y_data
y_data[v.name] = np.real(arr).tolist()
# Magnitude in dB
magnitude = np.abs(arr)
# Avoid log10(0) -- clamp to a very small value
magnitude = np.where(magnitude > 0, magnitude, 1e-30)
mag_db = 20.0 * np.log10(magnitude)
y_magnitude_db[v.name] = mag_db.tolist()
# Phase in degrees
phase = np.degrees(np.angle(arr))
y_phase_deg[v.name] = phase.tolist()
else:
for v in raw.variables[1:]:
arr = raw.data[v.index]
y_data[v.name] = arr.tolist()
return WaveformData(
variables=variables,
points=raw.points,
x_data=x_data,
y_data=y_data,
x_type=x_type,
is_complex=is_complex,
y_magnitude_db=y_magnitude_db,
y_phase_deg=y_phase_deg,
)

View File

@ -0,0 +1,96 @@
"""Shared raw-file-to-waveform conversion for all SPICE engines.
Both ngspice and mcltspice produce RawFile objects with the same structural
shape (data ndarray, variables list, flags, points). This module provides
a single converter that works with either via Protocol typing.
"""
from collections.abc import Sequence
from typing import Protocol
import numpy as np
from spicebook.models.simulation import WaveformData, WaveformVariable
class RawVariable(Protocol):
@property
def index(self) -> int: ...
@property
def name(self) -> str: ...
@property
def type(self) -> str: ...
class RawFilelike(Protocol):
@property
def flags(self) -> Sequence[str]: ...
@property
def variables(self) -> Sequence[RawVariable]: ...
@property
def points(self) -> int: ...
@property
def data(self) -> np.ndarray: ...
def raw_to_waveform(raw: RawFilelike) -> WaveformData:
"""Convert a parsed RawFile into a WaveformData response model.
Works with any raw-file object that satisfies the RawFilelike protocol
(ngspice RawFile, mcltspice RawFile, etc.).
"""
if not raw.variables:
raise ValueError("Raw file contains no variables -- simulation produced no output")
if raw.data.shape[0] == 0 or (raw.data.ndim > 1 and raw.data.shape[1] == 0):
raise ValueError(f"Raw file data is empty (shape {raw.data.shape})")
n_rows = raw.data.shape[0]
for v in raw.variables:
if v.index >= n_rows:
raise ValueError(
f"Variable '{v.name}' index {v.index} exceeds data rows ({n_rows})"
)
is_complex = "complex" in raw.flags
# First variable is always the sweep axis
x_var = raw.variables[0]
x_type = "frequency" if "frequency" in x_var.type.lower() else "time"
variables = [
WaveformVariable(name=v.name, type=v.type) for v in raw.variables[1:]
]
x_array = raw.data[0]
x_data = np.real(x_array).tolist() if is_complex else x_array.tolist()
y_data: dict[str, list[float]] = {}
y_magnitude_db: dict[str, list[float]] | None = None
y_phase_deg: dict[str, list[float]] | None = None
if is_complex:
y_magnitude_db = {}
y_phase_deg = {}
for v in raw.variables[1:]:
arr = raw.data[v.index]
y_data[v.name] = np.real(arr).tolist()
magnitude = np.abs(arr)
magnitude = np.where(magnitude > 0, magnitude, 1e-30)
y_magnitude_db[v.name] = (20.0 * np.log10(magnitude)).tolist()
y_phase_deg[v.name] = np.degrees(np.angle(arr)).tolist()
else:
for v in raw.variables[1:]:
y_data[v.name] = raw.data[v.index].tolist()
return WaveformData(
variables=variables,
points=raw.points,
x_data=x_data,
y_data=y_data,
x_type=x_type,
is_complex=is_complex,
y_magnitude_db=y_magnitude_db,
y_phase_deg=y_phase_deg,
)

View File

@ -0,0 +1,163 @@
"""Resistor color code bands mapped onto zigzag schematic symbols.
Maps standard 4-band resistor color codes directly onto the IEEE zigzag
(zigzag) symbol segments. Each zigzag sub-segment is colored to match
its corresponding band, teaching the color code while reading schematics.
Band-to-segment mapping (4-band):
Seg 0 (entry half-rise): wire color (default)
Seg 1 (peak-to-valley): 1st significant digit
Seg 2 (valley-to-peak): 2nd significant digit
Seg 3 (peak-to-valley): multiplier
Seg 4 (valley-to-peak): gap wire color (mimics physical spacing)
Seg 5 (peak-to-valley): tolerance
Seg 6 (exit half-rise): wire color (default)
"""
import re
from schemdraw.elements.twoterm import Element2Term
from schemdraw.segments import Segment
# ── SPICE Value Parser ──────────────────────────────────────
_SPICE_SUFFIXES = {
"T": 1e12,
"G": 1e9,
"MEG": 1e6,
"K": 1e3,
"M": 1e6, # For resistors, bare M = Mega (milliohm resistors are vanishingly rare)
"U": 1e-6,
"N": 1e-9,
"P": 1e-12,
"F": 1e-15,
}
_SUFFIX_RE = re.compile(
r"^([0-9]*\.?[0-9]+)\s*(MEG|[TGKMUNPF])?$",
re.IGNORECASE,
)
def parse_spice_value(value_str: str) -> float | None:
"""Convert a SPICE value string to numeric ohms.
Handles: "10k", "4.7MEG", "100", "2.2K", "0.1", "10k ic=0"
Returns None for parametric expressions like "{R_val}".
"""
if not value_str:
return None
token = value_str.strip().split()[0]
if token.startswith("{"):
return None
# Try bare numeric first
try:
return float(token)
except ValueError:
pass
m = _SUFFIX_RE.match(token)
if m:
number = float(m.group(1))
suffix = (m.group(2) or "").upper()
multiplier = _SPICE_SUFFIXES.get(suffix, 1.0)
return number * multiplier
return None
# ── Hex Colors Tuned for Mims Background (#f8faf6) ─────────
BAND_HEX: dict[str, str] = {
"black": "#1a1a1a",
"brown": "#8B4513",
"red": "#CC0000",
"orange": "#E67300",
"yellow": "#B8960B", # Dark goldenrod — visible on off-white
"green": "#006400",
"blue": "#0000CC",
"violet": "#7B00B3",
"gray": "#666666",
"white": "#D0D0D0", # Light gray — visible on off-white
"gold": "#B8860B",
"silver": "#808080",
"pink": "#FF69B4",
}
# ── Zigzag Geometry (matches SchemDraw 0.22 ResistorIEEE) ───
_RW = 1 / 6 # reswidth
_RH = 0.25 # resheight
_ZIGZAG_POINTS = [
(0, 0),
(0.5 * _RW, _RH),
(1.5 * _RW, -_RH),
(2.5 * _RW, _RH),
(3.5 * _RW, -_RH),
(4.5 * _RW, _RH),
(5.5 * _RW, -_RH),
(6 * _RW, 0),
]
# ── Custom Element ──────────────────────────────────────────
class ColorCodedResistor(Element2Term):
"""IEEE zigzag resistor with segments colored as standard color code bands.
Uses an overlay strategy: segments[0] is the full 8-point zigzag path
(needed by Element2Term._place() for lead extension), then individual
2-point colored segments are appended at a higher zorder so they render
on top, hiding the base path under the band colors.
"""
def __init__(self, value_ohms: float, tolerance_pct: float = 5, **kwargs):
super().__init__(**kwargs)
# segments[0]: full zigzag — required for Element2Term lead geometry
self.segments.append(Segment(_ZIGZAG_POINTS))
# Get 4-band color names from SchemDraw's existing logic
from schemdraw.pictorial.pictorial import resistor_colors
band_names = resistor_colors(value_ohms, tolerance_pct)
# Map band colors to the 7 zigzag sub-segments
hex_colors = _map_4band(band_names)
# Add colored overlay segments at higher zorder
for i, hex_color in enumerate(hex_colors):
if hex_color is not None:
self.segments.append(
Segment(
[_ZIGZAG_POINTS[i], _ZIGZAG_POINTS[i + 1]],
color=hex_color,
zorder=3,
)
)
def _map_4band(
band_names: tuple[str, str, str, str | None],
) -> list[str | None]:
"""Map 4-band color names to 7 zigzag sub-segment slots.
Returns a list of 7 hex color strings (or None for wire-colored segments).
The gap at seg 4 mimics the physical spacing before the tolerance band.
"""
c1, c2, c3, ct = band_names
return [
None, # seg 0: entry half-rise (wire)
BAND_HEX.get(c1), # seg 1: 1st significant digit
BAND_HEX.get(c2), # seg 2: 2nd significant digit
BAND_HEX.get(c3), # seg 3: multiplier
None, # seg 4: gap (wire — mimics physical spacing)
BAND_HEX.get(ct) if ct else None, # seg 5: tolerance
None, # seg 6: exit half-rise (wire)
]

View File

@ -71,6 +71,18 @@ class PlacedComponent:
GROUND_NAMES = {"0", "gnd"}
SUPPLY_NAMES = {"vcc", "vdd", "vee", "vss"}
# ── Connected Layout Constants ────────────────────────────────
# Replaces scattered magic numbers for label offsets, font sizes, and spacing.
_LABEL_OFST = 0.5 # general label offset from component body
_LABEL_OFST_TIGHT = 0.2 # horizontal single-component paths
_NET_LABEL_FONTSIZE = 10 # net/node labels (was 8-9)
_DEVICE_LABEL_OFST = 0.6 # active device body label offset (clear junction wires)
_PARALLEL_PATH_SPACING = 4.0 # between parallel vertical paths (was 2.5)
_LEAD_STUB_LENGTH = 2.0 # clearance from transistor pin to first component
_INPUT_JUNCTION_LENGTH = 1.5 # base/gate junction wire (was 1.0)
_VERT_CHAIN_LEN = 4.0 # default component length in vertical chains
_VERT_CHAIN_LEN_COMPACT = 3.0 # compact length for inductors, capacitors, diodes
def _is_ground(node: str) -> bool:
return node.lower() in GROUND_NAMES
@ -111,15 +123,12 @@ def parse_netlist(text: str) -> ParsedNetlist:
if not lines:
return ParsedNetlist()
# First non-blank, non-directive line is the title (SPICE convention)
title = ""
first_content = 0
for i, line in enumerate(lines):
stripped = line.strip()
if stripped and not stripped.startswith(("*", ";", ".")):
title = stripped
first_content = i + 1
break
# SPICE convention: line 1 is always the title, regardless of content.
# Strip leading * or ; (common title prefix) for display purposes.
title = lines[0].strip()
if title.startswith(("*", ";")):
title = title[1:].strip()
first_content = 1
# Pre-scan for .model statements to determine BJT/MOSFET polarity
models: dict[str, str] = {}
@ -492,7 +501,7 @@ def _plan_active_layout(
placed = set(exclude)
for tname, tnode in terminals.items():
tpaths = _trace_paths_from_terminal(tnode, tname, node_map, exclude)
tpaths = _trace_paths_from_terminal(tnode, tname, node_map, placed)
paths[tname] = tpaths
for p in tpaths:
for c in p.components:
@ -520,20 +529,38 @@ def _path_style(
term_name: str,
path: TerminalPath,
has_signal: bool,
is_inverted: bool,
supply_term: str,
output_term: str,
input_term: str,
) -> str:
"""Classify a path's drawing style: up, down, input, or output."""
"""Classify a path's drawing style: up, down, input, input_up, or output.
``input_up`` routes input-terminal bias paths (base/gate VCC) left then
up with a local Vdd symbol, keeping them on the input side of the
schematic and avoiding wire crossings with collector/drain vertical paths.
Direction classification is polarity-independent: supply up, ground
down. SchemDraw's BJT/FET symbols already orient supply terminals at the
top and ground terminals at the bottom for both N- and P-type devices.
``output_term`` is always collector (BJT) or drain (FET), regardless of
polarity the output coupling path originates from this terminal.
"""
if path.end_type == "supply":
return "up" if not is_inverted else "down"
if term_name == input_term:
return "input_up"
return "up"
if term_name == input_term and has_signal and len(path.components) > 1:
return "input"
if term_name == supply_term and path.end_type == "ground" and len(path.components) > 1:
if term_name == output_term and path.end_type == "ground" and len(path.components) > 1:
return "output"
if path.end_type == "ground":
return "down" if not is_inverted else "up"
return "down" if not is_inverted else "up"
return "down"
# Feedback / coupling paths ending at non-supply, non-ground nodes
# (e.g., collector→base feedback cap): route horizontally outward
# from the output terminal to avoid crossing through the body.
if term_name == output_term:
return "output"
return "down"
# ── Value Formatting ───────────────────────────────────────────
@ -579,11 +606,16 @@ def _get_element(comp: SpiceComponent, models: dict[str, str]):
prefix = comp.prefix
if prefix == "R":
from spicebook.engine.resistor_colors import ColorCodedResistor, parse_spice_value
ohms = parse_spice_value(comp.value)
if ohms is not None and 0.01 <= ohms <= 1e9:
return ColorCodedResistor(ohms)
return elm.Resistor()
elif prefix == "C":
return elm.Capacitor()
elif prefix == "L":
return elm.Inductor2()
return elm.Inductor2().fill(_GRID_BG)
elif prefix == "V":
return elm.SourceV()
elif prefix == "I":
@ -629,20 +661,20 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
path_comps = loop[1:]
if not path_comps:
# Standalone source
# Standalone source — loc="top" places label to geometric left of vertical element
d.add(
_get_element(source, parsed.models)
.up()
.label(_component_label(source), loc="left")
.label(_component_label(source), loc="top", ofst=_LABEL_OFST)
)
d.add(elm.Ground())
return d.get_imagedata("svg").decode()
# Source on left side, going up
# Source on left side, going up — loc="top" for geometric left
src = d.add(
_get_element(source, parsed.models)
.up()
.label(_component_label(source), loc="left")
.label(_component_label(source), loc="top", ofst=_LABEL_OFST)
)
# All components except the last go right across the top
@ -650,16 +682,16 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
d.add(
_get_element(comp, parsed.models)
.right()
.label(_component_label(comp))
.label(_component_label(comp), ofst=_LABEL_OFST)
)
# Last component goes down, ending at the same y-level as the source start
# Last component goes down — loc="bottom" for geometric right of vertical element
last_comp = path_comps[-1]
d.add(
_get_element(last_comp, parsed.models)
.down()
.toy(src.start)
.label(_component_label(last_comp), loc="right")
.label(_component_label(last_comp), loc="bottom", ofst=_LABEL_OFST)
)
# Return wire along the bottom back to source start
@ -674,7 +706,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
d.add(
elm.Dot(open=True)
.at(src.end)
.label(node_label, loc="left", fontsize=9)
.label(node_label, loc="left", fontsize=_NET_LABEL_FONTSIZE)
)
# Node label at junction between top components and the last (shunt) component
@ -1004,7 +1036,7 @@ def _render_grid(parsed: ParsedNetlist) -> str:
node_ys = [tiers.get(n, _GRID_MID_Y) for n in comp.nodes[:3]]
center_y = (max(node_ys) + min(node_ys)) / 2
placed_elem = d.add(
elem.at((x, center_y)).label(_component_label(comp))
elem.at((x, center_y)).label(_component_label(comp), loc="right", ofst=_LABEL_OFST)
)
elif len(comp.nodes) >= 2:
# 2-terminal: orient vertically between the two node tiers
@ -1016,19 +1048,19 @@ def _render_grid(parsed: ParsedNetlist) -> str:
# node[0] higher → go down so start=node[0], end=node[1]
placed_elem = d.add(
elem.at((x, y0)).down().length(length)
.label(_component_label(comp), loc="right")
.label(_component_label(comp), loc="right", ofst=_LABEL_OFST)
)
else:
# node[0] lower → go up so start=node[0], end=node[1]
placed_elem = d.add(
elem.at((x, y0)).up().length(length)
.label(_component_label(comp), loc="right")
.label(_component_label(comp), loc="right", ofst=_LABEL_OFST)
)
else:
# Fallback: single-node or unusual component
placed_elem = d.add(
elem.at((x, _GRID_MID_Y)).down().length(_GRID_MIN_COMP_LEN)
.label(_component_label(comp), loc="right")
.label(_component_label(comp), loc="right", ofst=_LABEL_OFST)
)
positions = _get_terminal_positions(placed_elem, comp)
@ -1054,7 +1086,7 @@ def _render_grid(parsed: ParsedNetlist) -> str:
px, py = positions[0]
d.add(
elm.Dot(open=True).at((px, py))
.label(node, loc="right", fontsize=9)
.label(node, loc="right", fontsize=_NET_LABEL_FONTSIZE)
)
continue
@ -1068,7 +1100,7 @@ def _render_grid(parsed: ParsedNetlist) -> str:
# Label at the midpoint of the wire
mx = (positions[0][0] + positions[1][0]) / 2
my = (positions[0][1] + positions[1][1]) / 2
d.add(elm.Label().at((mx, my)).label(node, loc="right", fontsize=9))
d.add(elm.Label().at((mx, my)).label(node, loc="right", fontsize=_NET_LABEL_FONTSIZE))
else:
# 3+ connections: star wiring with junction dot
_draw_star_wires(d, positions)
@ -1076,7 +1108,7 @@ def _render_grid(parsed: ParsedNetlist) -> str:
ys = sorted(p[1] for p in positions)
d.add(
elm.Label().at((xs[len(xs) // 2], ys[len(ys) // 2]))
.label(node, loc="right", fontsize=9)
.label(node, loc="right", fontsize=_NET_LABEL_FONTSIZE)
)
return d.get_imagedata("svg").decode()
@ -1091,14 +1123,14 @@ def _label_two_terminal(d, placed, comp: SpiceComponent) -> None:
d.add(
elm.Label()
.at(placed.start)
.label(comp.nodes[0], loc="left", fontsize=8)
.label(comp.nodes[0], loc="left", fontsize=_NET_LABEL_FONTSIZE)
)
if len(comp.nodes) >= 2:
d.add(elm.Dot(radius=0.08).at(placed.end))
d.add(
elm.Label()
.at(placed.end)
.label(comp.nodes[1], loc="right", fontsize=8)
.label(comp.nodes[1], loc="right", fontsize=_NET_LABEL_FONTSIZE)
)
@ -1124,10 +1156,47 @@ def _label_multiterminal(d, placed, comp: SpiceComponent) -> None:
d.add(
elm.Label()
.at(pos)
.label(label_text, fontsize=8)
.label(label_text, fontsize=_NET_LABEL_FONTSIZE)
)
# ── Connected Layout Helpers ─────────────────────────────────
def _choose_label_side(path_index: int, is_left_of_device: bool) -> str:
"""Pick label side so labels fan outward from the circuit center.
Even-indexed paths (0, 2, 4, ...) get their natural side.
Odd-indexed paths (1, 3, 5, ...) get the opposite side.
"""
natural = "left" if is_left_of_device else "right"
opposite = "right" if is_left_of_device else "left"
if path_index % 2 == 0:
return natural
return opposite
def _chain_length(comp: SpiceComponent) -> float:
"""Return drawing length for a component in vertical/horizontal chains.
Color-coded resistors need full length for readable band spacing.
Inductors, capacitors, and diodes use compact length their symbols
are inherently recognizable at smaller sizes.
"""
if comp.prefix in ("L", "C", "D"):
return _VERT_CHAIN_LEN_COMPACT
return _VERT_CHAIN_LEN
def _sort_parallel_paths(paths: list[TerminalPath]) -> list[TerminalPath]:
"""Sort parallel paths: longest chains closest to device (index 0).
Longer chains draw adjacent to the device body so their wires don't
cross shorter-path terminators offset further out.
"""
return sorted(paths, key=lambda p: -len(p.components))
# ── Connected Layout Renderer ────────────────────────────────
@ -1138,24 +1207,36 @@ def _draw_vert_chain(
label_loc controls which side labels appear on ("left" or "right").
Classic drafting rule: labels face outward from the circuit center.
SchemDraw note: for vertical elements, loc="left"/"right" place labels
at the element start/end (along the axis), not perpendicular to the body.
Use loc="top" for geometric-left and loc="bottom" for geometric-right.
"""
import schemdraw.elements as elm
direction = "up" if going_up else "down"
# Map logical left/right to perpendicular label positions for vertical elements
_vert_loc = {"left": "top", "right": "bottom"}
actual_loc = _vert_loc.get(label_loc, label_loc)
for i, comp in enumerate(components):
elem = _get_element(comp, parsed.models)
if i == 0 and start is not None:
elem = elem.at(start)
elem = getattr(elem, direction)()
elem = elem.label(_component_label(comp), loc=label_loc)
elem = getattr(elem, direction)().length(_chain_length(comp))
elem = elem.label(_component_label(comp), loc=actual_loc, ofst=_LABEL_OFST)
d.add(elem)
# Short gap wire before terminators so ground/Vdd symbols don't
# overlap the last component's label text.
if end_type in ("ground", "supply"):
d.add(getattr(elm.Line(), direction)().length(0.5))
if end_type == "ground":
d.add(elm.Ground())
elif end_type == "supply":
d.add(elm.Vdd().label(end_node.upper()))
else:
d.add(elm.Dot(open=True).label(end_node, fontsize=9))
d.add(elm.Dot(open=True).label(end_node, fontsize=_NET_LABEL_FONTSIZE))
def _draw_horiz_then_down(d, parsed, start, path, going_right):
@ -1173,15 +1254,18 @@ def _draw_horiz_then_down(d, parsed, start, path, going_right):
is_last = i == len(comps) - 1
if is_last and len(comps) > 1:
# Turn downward at the bend — label faces outward
# For vertical .down() elements: "bottom" = geometric right, "top" = geometric left
d.push()
down_label = "right" if going_right else "left"
d.add(elem.down().label(_component_label(comp), loc=down_label))
down_loc = "bottom" if going_right else "top"
d.add(elem.down().length(_chain_length(comp)).label(
_component_label(comp), loc=down_loc, ofst=_LABEL_OFST,
))
if path.end_type == "ground":
d.add(elm.Ground())
elif path.end_type == "supply":
d.add(elm.Vdd().label(path.end_node.upper()))
else:
d.add(elm.Dot(open=True).label(path.end_node, fontsize=9))
d.add(elm.Dot(open=True).label(path.end_node, fontsize=_NET_LABEL_FONTSIZE))
d.pop()
# Label the junction node at the bend
prev = comps[-2]
@ -1190,11 +1274,11 @@ def _draw_horiz_then_down(d, parsed, start, path, going_right):
if not _is_ground(node) and not _is_supply(node):
loc = "right" if going_right else "left"
d.add(
elm.Dot(open=True).label(node, loc=loc, fontsize=9)
elm.Dot(open=True).label(node, loc=loc, fontsize=_NET_LABEL_FONTSIZE)
)
break
else:
d.add(getattr(elem, h_dir)().label(_component_label(comp)))
d.add(getattr(elem, h_dir)().label(_component_label(comp), ofst=_LABEL_OFST_TIGHT))
# Single-component horizontal path ending at ground/supply/open
if len(comps) == 1:
@ -1203,7 +1287,7 @@ def _draw_horiz_then_down(d, parsed, start, path, going_right):
elif path.end_type == "supply":
d.add(elm.Vdd().label(path.end_node.upper()))
else:
d.add(elm.Dot(open=True).label(path.end_node, fontsize=9))
d.add(elm.Dot(open=True).label(path.end_node, fontsize=_NET_LABEL_FONTSIZE))
def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str:
@ -1223,23 +1307,31 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str:
# (Mims convention: transistor type label beside the symbol)
if layout.device_type.startswith("bjt"):
dev_elem = elm.BjtPnp() if is_inverted else elm.BjtNpn()
q = d.add(dev_elem.label(_component_label(layout.device), loc="right"))
q = d.add(dev_elem.label(
_component_label(layout.device), loc="right",
ofst=_DEVICE_LABEL_OFST, valign="center",
))
anchors = {
"collector": q.collector,
"base": q.base,
"emitter": q.emitter,
}
supply_term = "emitter" if is_inverted else "collector"
output_term = "collector"
input_term = "base"
else:
dev_elem = elm.PFet() if is_inverted else elm.NFet()
q = d.add(dev_elem.label(_component_label(layout.device), loc="right"))
q = d.add(dev_elem.label(
_component_label(layout.device), loc="right",
ofst=_DEVICE_LABEL_OFST, valign="center",
))
anchors = {
"drain": q.drain,
"gate": q.gate,
"source": q.source,
}
supply_term = "source" if is_inverted else "drain"
output_term = "drain"
input_term = "gate"
for term_name, term_paths in layout.paths.items():
@ -1252,11 +1344,12 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str:
down_paths: list[TerminalPath] = []
input_paths: list[TerminalPath] = []
output_paths: list[TerminalPath] = []
input_up_paths: list[TerminalPath] = []
for p in term_paths:
has_sig = any(c.name in signal_names for c in p.components)
style = _path_style(
term_name, p, has_sig, is_inverted, supply_term, input_term
term_name, p, has_sig, output_term, input_term
)
if style == "up":
up_paths.append(p)
@ -1264,41 +1357,54 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str:
down_paths.append(p)
elif style == "input":
input_paths.append(p)
elif style == "input_up":
input_up_paths.append(p)
else:
output_paths.append(p)
total = len(up_paths) + len(down_paths) + len(input_paths) + len(output_paths)
total = (
len(up_paths) + len(down_paths) + len(input_paths)
+ len(output_paths) + len(input_up_paths)
)
# Mims-style label placement: vertical labels go LEFT,
# keeping clear of the VCC/Ground terminators above/below.
# The transistor label sits on the RIGHT, so left is open.
# Offset parallel paths (i > 0) flip to RIGHT to face outward.
vert_label = "left"
# Mims-style label placement: vertical chain labels default to LEFT
# because the transistor body label already sits on the RIGHT.
# Parallel offset paths (i > 0) alternate sides to avoid overlap.
is_left = True
# Lead stub wires from collector/emitter create clearance from Q
# (Mims always drew short leads from transistor pins)
if term_name == input_term and total > 1:
# Base/gate: junction wire left for bias branching
junc = d.add(elm.Line().at(anchor).left(1))
junc = d.add(elm.Line().at(anchor).left(_INPUT_JUNCTION_LENGTH))
draw_from = junc.end
elif term_name == supply_term and (up_paths or output_paths):
# Collector/drain: short stub up for clearance
lead = d.add(elm.Line().at(anchor).up().length(0.75))
elif term_name == supply_term and (up_paths or down_paths or output_paths):
# Supply terminal stub: always route UP (away from body toward VCC).
# SchemDraw's BjtPnp/PFet already orient the supply terminal at
# the top of the symbol, so UP moves away from the body for both
# NPN and PNP. Longer when output paths also branch.
stub = _LEAD_STUB_LENGTH * (1.5 if output_paths else 1.0)
lead = d.add(elm.Line().at(anchor).up().length(stub))
draw_from = lead.end
elif term_name not in (supply_term, input_term) and (down_paths):
# Emitter/source: short stub down for clearance
lead = d.add(elm.Line().at(anchor).down().length(0.75))
elif term_name not in (supply_term, input_term) and (up_paths or down_paths):
# Ground terminal stub: always route DOWN (away from body toward GND).
lead = d.add(elm.Line().at(anchor).down().length(_LEAD_STUB_LENGTH))
draw_from = lead.end
else:
draw_from = anchor
# Sort all path lists: longest chains closest to device
up_paths = _sort_parallel_paths(up_paths)
down_paths = _sort_parallel_paths(down_paths)
output_paths = _sort_parallel_paths(output_paths)
input_paths = _sort_parallel_paths(input_paths)
# Vertical-up paths (toward supply rail)
for i, p in enumerate(up_paths):
d.push()
# Offset parallel paths flip label side to avoid overlap
loc = "right" if i > 0 else vert_label
loc = _choose_label_side(i, is_left)
if i > 0:
d.add(elm.Line().at(draw_from).right(2.5 * i))
d.add(elm.Line().at(draw_from).right(_PARALLEL_PATH_SPACING * i))
_draw_vert_chain(
d, parsed, None, p.components, True, p.end_type, p.end_node,
label_loc=loc,
@ -1313,9 +1419,9 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str:
# Vertical-down paths (toward ground)
for i, p in enumerate(down_paths):
d.push()
loc = "right" if i > 0 else vert_label
loc = _choose_label_side(i, is_left)
if i > 0:
d.add(elm.Line().at(draw_from).right(2.5 * i))
d.add(elm.Line().at(draw_from).right(_PARALLEL_PATH_SPACING * i))
_draw_vert_chain(
d, parsed, None, p.components, False, p.end_type, p.end_node,
label_loc=loc,
@ -1327,6 +1433,29 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str:
)
d.pop()
# Input-up paths: base/gate bias paths to VCC route left then up
# with their own local Vdd symbol — avoids crossing collector paths.
#
# Geometry: draw_from is already _INPUT_JUNCTION_LENGTH left of
# the transistor anchor (junction wire drawn above). The offset
# below is measured from draw_from, so the total distance from
# the transistor is _INPUT_JUNCTION_LENGTH + input_up_base.
# When input_paths also go left, add _PARALLEL_PATH_SPACING to
# clear the horizontal signal chain (C1→V1, etc.).
input_up_base = _INPUT_JUNCTION_LENGTH
if input_paths:
input_up_base += _PARALLEL_PATH_SPACING
input_up_paths = _sort_parallel_paths(input_up_paths)
for i, p in enumerate(input_up_paths):
d.push()
offset = input_up_base + i * _PARALLEL_PATH_SPACING
d.add(elm.Line().at(draw_from).left(offset))
_draw_vert_chain(
d, parsed, None, p.components, True, p.end_type, p.end_node,
label_loc="left",
)
d.pop()
# Output paths (right then down)
for p in output_paths:
d.push()
@ -1443,6 +1572,33 @@ def _add_graph_paper_bg(svg_str: str, pad: float = 15.0) -> str:
return ET.tostring(root, encoding="unicode")
def _add_text_halos(svg_str: str) -> str:
"""Add a white halo behind all text elements so labels stay readable
over crossing wires.
Uses paint-order: stroke first, then fill a thick white stroke
behind the text acts as a knockout mask without adding extra elements.
"""
ET.register_namespace("", _SVG_NS)
try:
root = ET.fromstring(svg_str)
except ET.ParseError:
return svg_str
ns = {"svg": _SVG_NS}
for text_el in root.findall(".//svg:text", ns):
style = text_el.get("style", "")
# Skip if already styled (shouldn't happen with SchemDraw output)
if "paint-order" in style:
continue
text_el.set("paint-order", "stroke fill")
text_el.set("stroke", _GRID_BG)
text_el.set("stroke-width", "4")
text_el.set("stroke-linejoin", "round")
return ET.tostring(root, encoding="unicode")
def annotate_svg(svg_str: str, parsed: ParsedNetlist) -> str:
"""Add data attributes to SVG text elements for interactive editing.
@ -1568,8 +1724,9 @@ def netlist_to_svg(netlist_text: str) -> SchematicResult:
if svg is None:
svg = _render_grid(parsed)
# Post-process: graph paper background, then data attributes
# Post-process: graph paper background, text halos, then data attributes
svg = _add_graph_paper_bg(svg)
svg = _add_text_halos(svg)
svg = annotate_svg(svg, parsed)
component_map = build_component_map(parsed)

View File

@ -4,22 +4,40 @@ import logging
import os
import shutil
import sys
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastmcp.utilities.lifespan import combine_lifespans
from spicebook.chat.llm import close_client
from spicebook.config import settings
from spicebook.routers import compose, notebooks, schematics, simulation, waveforms
from spicebook.mcp import mcp
from spicebook.routers import chat, compose, notebooks, schematics, simulation, waveforms
logger = logging.getLogger("spicebook")
@asynccontextmanager
async def lifespan(app: FastAPI):
_configure_logging()
_validate_ngspice()
_validate_ltspice()
_ensure_directories()
yield
await close_client()
mcp_app = mcp.http_app(path="/", stateless_http=True)
def create_app() -> FastAPI:
application = FastAPI(
title="SpiceBook",
description="Notebook interface for SPICE circuit simulation",
version="2026.02.13",
lifespan=combine_lifespans(lifespan, mcp_app.lifespan),
)
# CORS -- allow dev frontends + configurable extra origins
@ -66,16 +84,14 @@ def create_app() -> FastAPI:
application.include_router(schematics.router)
application.include_router(simulation.router)
application.include_router(waveforms.router)
application.include_router(chat.router)
@application.get("/health")
async def health():
return {"status": "ok", "version": "2026.02.13"}
@application.on_event("startup")
async def startup():
_configure_logging()
_validate_ngspice()
_ensure_directories()
# Mount MCP server at /mcp
application.mount("/mcp", mcp_app)
return application
@ -104,6 +120,20 @@ def _validate_ngspice() -> None:
)
def _validate_ltspice() -> None:
"""Check whether LTspice is available (never blocks startup)."""
try:
from mcltspice.config import validate_installation
ok, msg = validate_installation()
if ok:
logger.info("LTspice engine available: %s", msg)
else:
logger.info("LTspice engine not available: %s", msg)
except ImportError:
logger.info("LTspice engine not available: mcltspice not installed")
def _ensure_directories() -> None:
"""Create notebook directories if they don't exist."""
settings.notebook_dir.mkdir(parents=True, exist_ok=True)

View File

@ -0,0 +1,15 @@
from fastmcp import FastMCP
mcp = FastMCP(
"SpiceBook",
instructions=(
"MCP server for SpiceBook circuit simulation notebooks. "
"Create, manage, and simulate SPICE circuit notebooks. "
"Use list_notebooks to browse, get_notebook to fetch details, "
"simulate_netlist for standalone simulations, and run_cell "
"to execute notebook cells."
),
)
# Register tools, resources, and prompts
import spicebook.mcp.tools # noqa: E402, F401

View File

@ -0,0 +1,197 @@
"""MCP tools wrapping SpiceBook storage and engine functions."""
import json
import logging
import tempfile
from pathlib import Path
from spicebook.config import settings
from spicebook.engine import get_engine
from spicebook.mcp import mcp
from spicebook.storage.filesystem import (
create_notebook,
delete_notebook,
list_notebooks,
load_notebook,
save_notebook,
)
logger = logging.getLogger("spicebook.mcp")
@mcp.tool()
def list_all_notebooks() -> str:
"""List all SpiceBook notebooks with metadata.
Returns a JSON array of notebook summaries including id, title, engine,
tags, cell count, and modification date.
"""
summaries = list_notebooks(settings.notebook_dir)
return json.dumps(
[s.model_dump() for s in summaries],
indent=2,
)
@mcp.tool()
def get_notebook(notebook_id: str) -> str:
"""Fetch a complete SpiceBook notebook by its ID.
Returns the full notebook JSON including all cells, metadata, and outputs.
Args:
notebook_id: The unique notebook identifier (slug-based, e.g. 'rc-filter-a1b2c3d4')
"""
nb = load_notebook(settings.notebook_dir, notebook_id)
if nb is None:
return json.dumps({"error": f"Notebook '{notebook_id}' not found"})
return nb.model_dump_json(indent=2)
@mcp.tool()
def create_new_notebook(title: str, engine: str = "ngspice") -> str:
"""Create a new empty SpiceBook notebook.
Args:
title: Human-readable title for the notebook (e.g. 'Low-Pass RC Filter')
engine: Simulation engine to use ('ngspice' or 'ltspice')
"""
nb_id, nb = create_notebook(settings.notebook_dir, title, engine)
return json.dumps({"id": nb_id, "notebook": json.loads(nb.model_dump_json())})
@mcp.tool()
def delete_user_notebook(notebook_id: str) -> str:
"""Delete a user-created notebook. Example notebooks cannot be deleted.
Args:
notebook_id: The notebook ID to delete
"""
deleted = delete_notebook(settings.notebook_dir, notebook_id)
if deleted:
return json.dumps({"deleted": True, "notebook_id": notebook_id})
return json.dumps({
"deleted": False,
"error": f"Notebook '{notebook_id}' not found or is an example",
})
@mcp.tool()
async def simulate_netlist(netlist: str, engine: str = "ngspice") -> str:
"""Run a standalone SPICE simulation on a netlist string.
Args:
netlist: Complete SPICE netlist text (must include .end directive)
engine: Simulation engine to use ('ngspice')
"""
eng = get_engine(engine)
with tempfile.TemporaryDirectory() as tmpdir:
result = await eng.run(netlist, Path(tmpdir))
return result.model_dump_json(indent=2)
@mcp.tool()
async def run_cell(notebook_id: str, cell_id: str) -> str:
"""Run a SPICE cell within a notebook and save results.
Args:
notebook_id: The notebook containing the cell
cell_id: The ID of the SPICE cell to execute
"""
nb = load_notebook(settings.notebook_dir, notebook_id)
if nb is None:
return json.dumps({"error": f"Notebook '{notebook_id}' not found"})
cell = next((c for c in nb.cells if c.id == cell_id), None)
if cell is None:
return json.dumps({"error": f"Cell '{cell_id}' not found in notebook"})
if cell.type.value != "spice":
return json.dumps({"error": f"Cell '{cell_id}' is type '{cell.type.value}', not 'spice'"})
eng = get_engine(nb.metadata.engine)
with tempfile.TemporaryDirectory() as tmpdir:
result = await eng.run(cell.source, Path(tmpdir))
# Update cell outputs (preserve schematics)
from datetime import datetime, timezone
from spicebook.models.notebook import CellOutput
sim_output = CellOutput(
output_type="simulation_result" if result.success else "error",
data={
"success": result.success,
"waveform": result.waveform.model_dump() if result.waveform else None,
"log": result.log,
"error": result.error,
"elapsed_seconds": result.elapsed_seconds,
},
timestamp=datetime.now(timezone.utc).isoformat(),
)
preserved = [o for o in cell.outputs if o.output_type == "schematic"]
cell.outputs = [sim_output, *preserved]
save_notebook(settings.notebook_dir, notebook_id, nb)
return result.model_dump_json(indent=2)
# ── Resource ──────────────────────────────────────────────────────
@mcp.resource("spicebook://status")
def spicebook_status() -> str:
"""SpiceBook server status: notebook count, available engines, and storage path."""
summaries = list_notebooks(settings.notebook_dir)
engines = ["ngspice"]
if settings.ltspice_dir:
engines.append("ltspice")
return json.dumps({
"notebook_count": len(summaries),
"engines": engines,
"notebook_dir": str(settings.notebook_dir.resolve()),
})
# ── Prompt ────────────────────────────────────────────────────────
@mcp.prompt()
def circuit_assistant(topic: str = "general") -> str:
"""Guided circuit design workflow prompt for SpiceBook.
Args:
topic: Focus area 'general', 'filter', 'amplifier', 'power', 'digital'
"""
topics = {
"general": (
"You are a circuit design assistant working with SpiceBook notebooks. "
"Help the user design, simulate, and analyze circuits. Start by asking "
"what kind of circuit they want to build, then guide them through "
"component selection, netlist creation, and simulation analysis."
),
"filter": (
"You are a filter design specialist. Help the user design passive or "
"active filters (low-pass, high-pass, band-pass, notch). Guide them "
"through cutoff frequency selection, component calculations, and "
"AC analysis interpretation using SpiceBook."
),
"amplifier": (
"You are an amplifier design specialist. Help the user design "
"transistor or op-amp amplifier circuits. Guide them through "
"biasing, gain calculations, frequency response, and transient "
"analysis using SpiceBook."
),
"power": (
"You are a power supply design specialist. Help the user design "
"voltage regulators, converters, and power distribution circuits. "
"Guide them through component selection, efficiency analysis, and "
"transient response using SpiceBook."
),
"digital": (
"You are a digital circuit design specialist. Help the user design "
"logic gates, flip-flops, and timing circuits using SPICE models. "
"Guide them through timing analysis and signal integrity using SpiceBook."
),
}
return topics.get(topic, topics["general"])

View File

@ -0,0 +1,18 @@
"""Pydantic models for the chat streaming endpoint."""
from pydantic import BaseModel, Field
class NotebookContext(BaseModel):
"""Context about the notebook the user is currently viewing."""
notebook_id: str = Field("", max_length=200)
title: str = Field("", max_length=200)
engine: str = Field("ngspice", max_length=50)
class ChatStreamRequest(BaseModel):
"""Request body for the SSE chat streaming endpoint."""
question: str = Field(..., min_length=1, max_length=2000)
notebook: NotebookContext | None = None

View File

@ -47,6 +47,8 @@ class NotebookSummary(BaseModel):
tags: list[str]
cell_count: int
modified: str
description: str | None = None
schematic_svg: str | None = None
class CreateNotebookRequest(BaseModel):

View File

@ -0,0 +1,109 @@
"""SSE streaming chat endpoint for the SpiceBook chat widget."""
import asyncio
import json
import logging
import httpx
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from spicebook.chat.llm import chat_completion_stream
from spicebook.config import settings
from spicebook.models.chat import ChatStreamRequest
from spicebook.storage.filesystem import load_notebook
logger = logging.getLogger("spicebook.chat")
router = APIRouter(prefix="/api/chat", tags=["chat"])
def _sse_event(event: str, data: dict | list) -> str:
payload = json.dumps(data, separators=(",", ":"))
return f"event: {event}\ndata: {payload}\n\n"
async def _build_notebook_context(req: ChatStreamRequest) -> str:
"""Extract SPICE cells and markdown notes from the notebook for LLM context."""
if not req.notebook or not req.notebook.notebook_id:
return ""
nb = await asyncio.to_thread(load_notebook, settings.notebook_dir, req.notebook.notebook_id)
if nb is None:
return ""
parts = [f'Notebook: "{nb.metadata.title}" (engine: {nb.metadata.engine})']
for i, cell in enumerate(nb.cells):
if cell.type.value == "spice" and cell.source.strip():
parts.append(f"\n--- SPICE Cell {i + 1} ---\n{cell.source.strip()}")
# Include latest simulation result summary if available
for output in cell.outputs:
if output.output_type in ("simulation_result", "error"):
success = output.data.get("success", False)
if not success and output.data.get("error"):
parts.append(f" [Simulation error: {output.data['error']}]")
elif success and output.data.get("waveform"):
wf = output.data["waveform"]
var_names = [v.get("name", "") for v in wf.get("variables", [])]
sig_list = ", ".join(var_names)
pts = wf.get("points", 0)
parts.append(f" [Simulation OK: {pts} points, signals: {sig_list}]")
break
elif cell.type.value == "markdown" and cell.source.strip():
# Include markdown notes (truncated)
text = cell.source.strip()[:500]
parts.append(f"\n--- Notes ---\n{text}")
return "\n".join(parts)
@router.post("/stream")
async def chat_stream(req: ChatStreamRequest):
"""SSE streaming endpoint for the chat widget."""
async def generate():
# Build context from the notebook if available
context = await _build_notebook_context(req)
question = req.question
if req.notebook and req.notebook.title:
nb_title = req.notebook.title
nb_engine = req.notebook.engine
question = (
f'[User is viewing notebook: "{nb_title}" ({nb_engine})]\n\n'
+ req.question
)
yield _sse_event("status", {"text": "Analyzing circuit context\u2026"})
try:
async for kind, text in chat_completion_stream(context, question):
yield _sse_event(
"reasoning" if kind == "reasoning" else "token",
{"text": text},
)
except (
httpx.HTTPStatusError,
httpx.ConnectError,
httpx.ReadTimeout,
httpx.PoolTimeout,
httpx.ConnectTimeout,
) as exc:
logger.warning("Chat stream failed: %s", exc)
yield _sse_event("error", {"text": "Chat service unavailable"})
return
except asyncio.CancelledError:
logger.debug("Chat stream cancelled by client disconnect")
return
yield _sse_event("done", {})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)

View File

@ -9,7 +9,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from spicebook.config import settings
from spicebook.engine import get_engine
from spicebook.engine import UnsupportedEngineError, get_engine
from spicebook.models.notebook import (
Cell,
CellOutput,
@ -57,7 +57,10 @@ async def compose_notebook(req: ComposeNotebookRequest):
)
if req.run:
engine = get_engine(req.engine)
try:
engine = get_engine(req.engine)
except UnsupportedEngineError as exc:
raise HTTPException(status_code=400, detail=str(exc))
for cell in notebook.cells:
if cell.type != CellType.SPICE:
continue

View File

@ -7,7 +7,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from spicebook.config import settings
from spicebook.engine import get_engine
from spicebook.engine import UnsupportedEngineError, get_engine
from spicebook.models.notebook import CellOutput, CellType
from spicebook.models.simulation import SimulationRequest, SimulationResponse
from spicebook.storage.filesystem import load_notebook, save_notebook
@ -18,7 +18,10 @@ router = APIRouter(prefix="/api", tags=["simulation"])
@router.post("/simulate", response_model=SimulationResponse)
async def simulate(req: SimulationRequest):
"""Run a standalone SPICE simulation."""
engine = get_engine(req.engine)
try:
engine = get_engine(req.engine)
except UnsupportedEngineError as exc:
raise HTTPException(status_code=400, detail=str(exc))
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
result = await engine.run(req.netlist, Path(tmpdir))
@ -54,7 +57,10 @@ async def run_cell(notebook_id: str, cell_id: str):
if not cell.source.strip():
raise HTTPException(status_code=400, detail="Cell source is empty")
engine = get_engine(nb.metadata.engine)
try:
engine = get_engine(nb.metadata.engine)
except UnsupportedEngineError as exc:
raise HTTPException(status_code=400, detail=str(exc))
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
result = await engine.run(cell.source, Path(tmpdir))

View File

@ -2,7 +2,9 @@
import json
import logging
import os
import re
import tempfile
import uuid
from datetime import datetime, timezone
from pathlib import Path
@ -19,6 +21,18 @@ logger = logging.getLogger(__name__)
SPICEBOOK_EXT = ".spicebook"
_SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,198}[a-z0-9]$")
def validate_notebook_id(notebook_id: str) -> str:
"""Validate notebook_id is a safe filesystem slug. Raises ValueError if not."""
if not _SAFE_ID_RE.match(notebook_id):
raise ValueError(
f"Invalid notebook ID: {notebook_id!r}. "
"Must be lowercase alphanumeric with hyphens, 2-200 chars."
)
return notebook_id
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@ -51,6 +65,44 @@ def _notebook_id_from_path(path: Path) -> str:
return path.stem
def _extract_card_data(nb: Notebook) -> tuple[str | None, str | None]:
"""Extract a description snippet and schematic SVG from notebook cells.
- description: text from the first markdown cell, with the title header
stripped and truncated to ~200 characters.
- schematic_svg: SVG string from the first successful schematic output.
"""
description: str | None = None
schematic_svg: str | None = None
for cell in nb.cells:
if description is None and cell.type == CellType.MARKDOWN and cell.source.strip():
lines = cell.source.strip().splitlines()
# Strip leading "# Title" header line
if lines and lines[0].lstrip().startswith("#"):
lines = lines[1:]
text = " ".join(line.strip() for line in lines if line.strip())
# Strip basic markdown syntax
text = re.sub(r"[*_`~\[\]#>]", "", text).strip()
if text:
description = text[:200].rstrip()
if schematic_svg is None:
for output in cell.outputs:
if (
output.output_type == "schematic"
and output.data.get("success")
and output.data.get("svg")
):
schematic_svg = output.data["svg"]
break
if description is not None and schematic_svg is not None:
break
return description, schematic_svg
def list_notebooks(directory: Path) -> list[NotebookSummary]:
"""List all notebooks in the directory and its examples/ subdirectory."""
summaries: list[NotebookSummary] = []
@ -66,6 +118,10 @@ def list_notebooks(directory: Path) -> list[NotebookSummary]:
try:
nb = _read_notebook_file(path)
if nb is None:
logger.warning("Skipping empty/corrupt notebook: %s", path)
continue
description, schematic_svg = _extract_card_data(nb)
summaries.append(NotebookSummary(
id=nb_id,
title=nb.metadata.title,
@ -73,6 +129,8 @@ def list_notebooks(directory: Path) -> list[NotebookSummary]:
tags=nb.metadata.tags,
cell_count=len(nb.cells),
modified=nb.metadata.modified,
description=description,
schematic_svg=schematic_svg,
))
except Exception:
logger.warning("Skipping corrupt notebook: %s", path)
@ -82,6 +140,7 @@ def list_notebooks(directory: Path) -> list[NotebookSummary]:
def load_notebook(directory: Path, notebook_id: str) -> Notebook | None:
"""Load a notebook by ID, searching user/ then examples/ subdirectories."""
validate_notebook_id(notebook_id)
for sub in ["user", "examples"]:
path = directory / sub / f"{notebook_id}{SPICEBOOK_EXT}"
if path.is_file():
@ -91,16 +150,25 @@ def load_notebook(directory: Path, notebook_id: str) -> Notebook | None:
def save_notebook(directory: Path, notebook_id: str, notebook: Notebook) -> None:
"""Save a notebook to the user/ subdirectory (updates modified timestamp)."""
validate_notebook_id(notebook_id)
user_dir = directory / "user"
user_dir.mkdir(parents=True, exist_ok=True)
notebook.metadata.modified = _now_iso()
path = user_dir / f"{notebook_id}{SPICEBOOK_EXT}"
path.write_text(
notebook.model_dump_json(indent=2),
encoding="utf-8",
)
content = notebook.model_dump_json(indent=2)
# Atomic write: write to temp file then rename, so a crash can't leave an empty file
fd, tmp = tempfile.mkstemp(dir=user_dir, suffix=".tmp")
try:
os.write(fd, content.encode("utf-8"))
os.fsync(fd)
os.close(fd)
os.replace(tmp, path)
except BaseException:
os.close(fd)
os.unlink(tmp)
raise
def create_notebook(directory: Path, title: str, engine: str = "ngspice") -> tuple[str, Notebook]:
@ -133,6 +201,7 @@ def delete_notebook(directory: Path, notebook_id: str) -> bool:
Returns True if deleted, False if not found.
"""
validate_notebook_id(notebook_id)
path = directory / "user" / f"{notebook_id}{SPICEBOOK_EXT}"
if path.is_file():
path.unlink()
@ -140,8 +209,13 @@ def delete_notebook(directory: Path, notebook_id: str) -> bool:
return False
def _read_notebook_file(path: Path) -> Notebook:
"""Read and validate a .spicebook JSON file."""
def _read_notebook_file(path: Path) -> Notebook | None:
"""Read and validate a .spicebook JSON file. Returns None on corrupt/empty files."""
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
if not raw.strip():
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
return None
return Notebook.model_validate(data)

View File

@ -0,0 +1,127 @@
"""Integration tests for LtspiceEngine with RC circuits.
Skipped automatically when mcltspice + Wine + LTspice aren't available
(e.g., Docker production, CI).
"""
import tempfile
from pathlib import Path
import pytest
# Determine availability at import time for skip markers
_ltspice_available = False
try:
from mcltspice.config import validate_installation
_ltspice_available, _ = validate_installation()
except ImportError:
pass
pytestmark = pytest.mark.skipif(
not _ltspice_available,
reason="LTspice not available (mcltspice + Wine + LTspice.exe required)",
)
from spicebook.engine.ltspice import LtspiceEngine # noqa: E402
RC_TRANSIENT_NETLIST = """\
RC Low-Pass Filter - Transient
V1 in 0 PULSE(0 5 0 1n 1n 5m 10m)
R1 in out 1k
C1 out 0 1u
.tran 100u 20m
.end
"""
RC_AC_NETLIST = """\
RC Low-Pass Filter - AC
V1 in 0 AC 1
R1 in out 1k
C1 out 0 1u
.ac dec 50 1 1Meg
.end
"""
@pytest.fixture
def engine():
return LtspiceEngine()
@pytest.fixture
def work_dir():
d = tempfile.mkdtemp(prefix="spicebook-ltspice-test-")
yield Path(d)
import shutil
shutil.rmtree(d, ignore_errors=True)
@pytest.mark.asyncio
async def test_transient_simulation(engine, work_dir):
"""Run a transient analysis via LTspice and verify waveform output."""
result = await engine.run(RC_TRANSIENT_NETLIST, work_dir)
assert result.success, f"Simulation failed: {result.error}\n{result.log}"
assert result.waveform is not None
assert result.waveform.x_type == "time"
assert result.waveform.points > 0
assert len(result.waveform.x_data) == result.waveform.points
assert result.elapsed_seconds > 0
# Should have at least one signal in y_data
assert len(result.waveform.y_data) > 0
# Check that V(out) or similar exists
signal_names = list(result.waveform.y_data.keys())
has_out = any("out" in name.lower() for name in signal_names)
assert has_out, f"Expected 'out' signal in {signal_names}"
# Verify data length matches
for name, values in result.waveform.y_data.items():
assert len(values) == result.waveform.points, f"Length mismatch for {name}"
@pytest.mark.asyncio
async def test_ac_simulation(engine, work_dir):
"""Run an AC analysis via LTspice and verify complex waveform output."""
result = await engine.run(RC_AC_NETLIST, work_dir)
assert result.success, f"Simulation failed: {result.error}\n{result.log}"
assert result.waveform is not None
assert result.waveform.x_type == "frequency"
assert result.waveform.is_complex
assert result.waveform.points > 0
# AC analysis should produce magnitude and phase data
assert result.waveform.y_magnitude_db is not None
assert result.waveform.y_phase_deg is not None
assert len(result.waveform.y_magnitude_db) > 0
assert len(result.waveform.y_phase_deg) > 0
@pytest.mark.asyncio
async def test_netlist_without_end_directive(engine, work_dir):
"""Engine should auto-append .end if missing."""
netlist_no_end = """\
Simple Resistor Divider
V1 in 0 DC 10
R1 in out 1k
R2 out 0 1k
.tran 1u 1m
"""
result = await engine.run(netlist_no_end, work_dir)
assert result.success, f"Simulation failed: {result.error}\n{result.log}"
@pytest.mark.asyncio
async def test_invalid_netlist(engine, work_dir):
"""Malformed netlist should return success=False with error info."""
bad_netlist = """\
This is not a valid SPICE netlist
.tran 1m 10m
.end
"""
result = await engine.run(bad_netlist, work_dir)
assert isinstance(result.success, bool)

796
backend/uv.lock generated
View File

@ -2,6 +2,18 @@ version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "aiofile"
version = "3.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "caio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
@ -33,6 +45,66 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "authlib"
version = "1.6.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6b/6c/c88eac87468c607f88bc24df1f3b31445ee6fc9ba123b09e666adf687cd9/authlib-1.6.8.tar.gz", hash = "sha256:41ae180a17cf672bc784e4a518e5c82687f1fe1e98b0cafaeda80c8e4ab2d1cb", size = 165074, upload-time = "2026-02-14T04:02:17.941Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/73/f7084bf12755113cd535ae586782ff3a6e710bfbe6a0d13d1c2f81ffbbfa/authlib-1.6.8-py2.py3-none-any.whl", hash = "sha256:97286fd7a15e6cfefc32771c8ef9c54f0ed58028f1322de6a2a7c969c3817888", size = 244116, upload-time = "2026-02-14T04:02:15.579Z" },
]
[[package]]
name = "beartype"
version = "0.22.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" },
]
[[package]]
name = "cachetools"
version = "7.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/07/56595285564e90777d758ebd383d6b0b971b87729bbe2184a849932a3736/cachetools-7.0.1.tar.gz", hash = "sha256:e31e579d2c5b6e2944177a0397150d312888ddf4e16e12f1016068f0c03b8341", size = 36126, upload-time = "2026-02-10T22:24:05.03Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" },
]
[[package]]
name = "caio"
version = "0.9.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" },
{ url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" },
{ url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" },
{ url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" },
{ url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" },
{ url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" },
{ url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" },
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
@ -42,6 +114,63 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "click"
version = "8.3.1"
@ -63,6 +192,126 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "46.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
]
[[package]]
name = "cyclopts"
version = "4.5.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "docstring-parser" },
{ name = "rich" },
{ name = "rich-rst" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3b/d2/f37df900b163f51b4faacdb01bf4895c198906d67c5b2a85c2522de85459/cyclopts-4.5.4.tar.gz", hash = "sha256:eed4d6c76d4391aa796d8fcaabd50e5aad7793261792beb19285f62c5c456c8b", size = 162438, upload-time = "2026-02-20T00:58:46.161Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/0f/119fa63fa93e0a331fbedcb27162d8f88d3ba2f38eba1567e3e44307b857/cyclopts-4.5.4-py3-none-any.whl", hash = "sha256:ad001986ec403ca1dc1ed20375c439d62ac796295ea32b451dfe25d6696bc71a", size = 200225, upload-time = "2026-02-20T00:58:47.275Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "docstring-parser"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
]
[[package]]
name = "docutils"
version = "0.22.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "fastapi"
version = "0.129.0"
@ -79,6 +328,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
]
[[package]]
name = "fastmcp"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "authlib" },
{ name = "cyclopts" },
{ name = "exceptiongroup" },
{ name = "httpx" },
{ name = "jsonref" },
{ name = "jsonschema-path" },
{ name = "mcp" },
{ name = "openapi-pydantic" },
{ name = "opentelemetry-api" },
{ name = "packaging" },
{ name = "platformdirs" },
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
{ name = "pydantic", extra = ["email"] },
{ name = "pyperclip" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "rich" },
{ name = "uvicorn" },
{ name = "watchfiles" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/11/6b/1a7ec89727797fb07ec0928e9070fa2f45e7b35718e1fe01633a34c35e45/fastmcp-3.0.2.tar.gz", hash = "sha256:6bd73b4a3bab773ee6932df5249dcbcd78ed18365ed0aeeb97bb42702a7198d7", size = 17239351, upload-time = "2026-02-22T16:32:28.843Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/5a/f410a9015cfde71adf646dab4ef2feae49f92f34f6050fcfb265eb126b30/fastmcp-3.0.2-py3-none-any.whl", hash = "sha256:f513d80d4b30b54749fe8950116b1aab843f3c293f5cb971fc8665cb48dbb028", size = 606268, upload-time = "2026-02-22T16:32:30.992Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@ -145,6 +425,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]]
name = "idna"
version = "3.11"
@ -154,6 +443,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@ -163,6 +464,182 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" },
]
[[package]]
name = "jaraco-context"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" },
]
[[package]]
name = "jaraco-functools"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
]
[[package]]
name = "jeepney"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" },
]
[[package]]
name = "jsonref"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-path"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pathable" },
{ name = "pyyaml" },
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/8d/4b2e648cf643d19e1f76260d9cb002d242e38b4298d6da110bd3c3d8d0d2/jsonschema_path-0.4.1.tar.gz", hash = "sha256:ffca3bd37f66364ae3afeaa2804d6078a9ab3b9359ade4dd9923aabbbd475e71", size = 13450, upload-time = "2026-02-20T10:09:41.611Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/5a/5a735fd9c889fd7ee214525620ead725861f9f4ddd27097408b63e596b06/jsonschema_path-0.4.1-py3-none-any.whl", hash = "sha256:727d8714158c41327908677e6119f9db9d5e0f486d4cc79ca4b4016eee2f33e8", size = 16745, upload-time = "2026-02-20T10:09:40.03Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "keyring"
version = "25.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mcltspice"
version = "2026.3.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastmcp" },
{ name = "numpy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/ef/2cadafe520e8b87a74c976dbc45c9a90a04d8b5364ded50b274d5c783232/mcltspice-2026.3.5-py3-none-any.whl", hash = "sha256:35107b8f98455805dbb10aaf5ee30441d9700268c018f487f4da79c61c8c5217", size = 111326, upload-time = "2026-03-05T21:38:32.895Z" },
]
[[package]]
name = "mcp"
version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "more-itertools"
version = "10.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431, upload-time = "2025-09-02T15:23:11.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667, upload-time = "2025-09-02T15:23:09.635Z" },
]
[[package]]
name = "numpy"
version = "2.4.2"
@ -224,6 +701,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
]
[[package]]
name = "openapi-pydantic"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.39.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
]
[[package]]
name = "packaging"
version = "26.0"
@ -233,6 +735,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pathable"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@ -242,6 +762,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "py-key-value-aio"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beartype" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" },
]
[package.optional-dependencies]
filetree = [
{ name = "aiofile" },
{ name = "anyio" },
]
keyring = [
{ name = "keyring" },
]
memory = [
{ name = "cachetools" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
@ -257,6 +811,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
@ -328,6 +887,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
@ -337,6 +910,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyjwt"
version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pyperclip"
version = "1.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
@ -375,6 +971,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]
name = "pywin32"
version = "311"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
@ -421,6 +1051,127 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "rich-rst"
version = "1.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docutils" },
{ name = "rich" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" },
]
[[package]]
name = "rpds-py"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
{ url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
{ url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
{ url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
{ url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
{ url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
{ url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
{ url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
{ url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
]
[[package]]
name = "ruff"
version = "0.15.1"
@ -455,12 +1206,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/ab/b281071bc11555670a82c538e4be35e7f5ad5e4cb4de7ac356a05a4b2c6a/schemdraw-0.22-py3-none-any.whl", hash = "sha256:fb294fe086b89a7dc9bedce43dc39014a9b9e369864eab438f22009c9f0e1175", size = 148359, upload-time = "2025-11-30T23:42:04.794Z" },
]
[[package]]
name = "secretstorage"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "jeepney" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
]
[[package]]
name = "spicebook"
version = "2026.2.13"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "fastmcp" },
{ name = "httpx" },
{ name = "numpy" },
{ name = "pydantic" },
{ name = "schemdraw" },
@ -475,11 +1241,17 @@ dev = [
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
ltspice = [
{ name = "mcltspice" },
]
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "fastmcp", specifier = ">=3.0.0" },
{ name = "httpx", specifier = ">=0.28.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" },
{ name = "mcltspice", marker = "extra == 'ltspice'", specifier = ">=2026.2.14" },
{ name = "numpy", specifier = ">=1.24.0" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
@ -489,7 +1261,20 @@ requires-dist = [
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
{ name = "websockets", specifier = ">=12.0" },
]
provides-extras = ["dev"]
provides-extras = ["ltspice", "dev"]
[[package]]
name = "sse-starlette"
version = "3.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" },
]
[[package]]
name = "starlette"
@ -695,3 +1480,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]

View File

@ -4,23 +4,56 @@ services:
context: ./backend
dockerfile: Dockerfile
target: dev
ports:
- "${BACKEND_PORT:-8099}:8000"
expose:
- "8000"
volumes:
- ./backend/src:/app/src
- ./notebooks:/app/notebooks
# Mount mcltspice for live development
- ../mcp-ltspice/src/mcltspice:/app/mcltspice-lib:ro
command: ["uv", "run", "uvicorn", "spicebook.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
environment:
- PYTHONPATH=/app/src
command: ["python", "-m", "uvicorn", "spicebook.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
networks:
- default
- caddy
labels:
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc /mcp/*"
caddy.reverse_proxy_0: "@api {{upstreams 8000}}"
caddy.reverse_proxy_0.flush_interval: "-1"
caddy.reverse_proxy_0.transport: "http"
caddy.reverse_proxy_0.transport.read_timeout: "0"
caddy.reverse_proxy_0.transport.write_timeout: "0"
caddy.reverse_proxy_0.stream_timeout: "24h"
caddy.reverse_proxy_0.stream_close_delay: "5s"
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: dev
ports:
- "${FRONTEND_PORT:-4321}:4321"
expose:
- "4321"
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
networks:
- default
- caddy
labels:
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
caddy.reverse_proxy_1: "{{upstreams 4321}}"
caddy.reverse_proxy_1.flush_interval: "-1"
caddy.reverse_proxy_1.transport: "http"
caddy.reverse_proxy_1.transport.read_timeout: "0"
caddy.reverse_proxy_1.transport.write_timeout: "0"
caddy.reverse_proxy_1.transport.keepalive: "5m"
caddy.reverse_proxy_1.transport.keepalive_idle_conns: "10"
caddy.reverse_proxy_1.stream_timeout: "24h"
caddy.reverse_proxy_1.stream_close_delay: "5s"
networks:
caddy:
external: true

View File

@ -6,13 +6,36 @@ services:
target: prod
expose:
- "8000"
# LTspice engine is GATED. The backend image ships Wine 11.10 + the prefix,
# the host carries ./ltspice (exe/lib/examples), but LTspice v26's threads
# spin in userspace during startup under headless Wine in this slim image
# (runs fine on the dev desktop). Without LTSPICE_DIR the engine reports
# "unavailable" and fails fast instead of hanging.
#
# Ruled out (don't re-tread): Wine version (11.10 matches dev), the ini
# first-run seed, the X display (Xvfb up), GL/EGL/vulkan libs, fonts,
# 32-bit subsystem, color depth, and the entire CUPS/print path (cupsd up,
# CUPS_SERVER, libcups removed -- all still spin). Next: a userspace
# backtrace of the spinning thread, or pivot to running mcltspice on a
# real desktop and calling it remotely. To resume, uncomment both blocks:
# volumes:
# - ./ltspice:/opt/ltspice-src:ro
# environment:
# - LTSPICE_DIR=/opt/ltspice
# - DISPLAY=:99
networks:
- default
- caddy
labels:
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc"
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc /mcp/*"
caddy.reverse_proxy_0: "@api {{upstreams 8000}}"
caddy.reverse_proxy_0.flush_interval: "-1"
caddy.reverse_proxy_0.transport: "http"
caddy.reverse_proxy_0.transport.read_timeout: "0"
caddy.reverse_proxy_0.transport.write_timeout: "0"
caddy.reverse_proxy_0.stream_timeout: "24h"
caddy.reverse_proxy_0.stream_close_delay: "5s"
frontend:
build:
@ -27,6 +50,7 @@ services:
labels:
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
caddy.reverse_proxy_1: "{{upstreams 4321}}"
caddy.reverse_proxy_1.flush_interval: "-1"
networks:
caddy:

View File

@ -2,6 +2,7 @@ services:
backend:
build:
context: ./backend
restart: unless-stopped
env_file: .env
volumes:
- ./notebooks:/app/notebooks
@ -14,7 +15,10 @@ services:
frontend:
build:
context: ./frontend
restart: unless-stopped
env_file: .env
environment:
- PUBLIC_API_URL=https://${SPICEBOOK_DOMAIN:-localhost:4321}
depends_on:
backend:
condition: service_healthy

6
docs-site/.dockerignore Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
.astro
.env
.git
*.md

10
docs-site/.env.example Normal file
View File

@ -0,0 +1,10 @@
# SpiceBook docs environment configuration
COMPOSE_PROJECT_NAME=spicebook-docs
# MODE: dev | prod
MODE=dev
# Domain configuration
DOMAIN=spicebook-docs.l.warehack.ing
# Production: spicebook-docs.warehack.ing
# Dev (local): spicebook-docs.l.warehack.ing

4
docs-site/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.astro/
.env

68
docs-site/Dockerfile Normal file
View File

@ -0,0 +1,68 @@
# ── Dev target ────────────────────────────────────────────────
FROM node:22-slim AS dev
WORKDIR /app
COPY package*.json ./
COPY patches/ patches/
RUN npm ci
COPY . .
EXPOSE 4321
CMD ["npm", "run", "dev"]
# ── Prod build stage ─────────────────────────────────────────
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
COPY patches/ patches/
RUN npm ci
COPY . .
RUN npm run build
# ── Prod serve stage ─────────────────────────────────────────
FROM caddy:2-alpine AS prod
RUN addgroup -g 1001 -S app && \
adduser -S app -u 1001 -G app
COPY --from=build /app/dist /srv
COPY <<'CADDYFILE' /etc/caddy/Caddyfile
:8080 {
root * /srv
file_server
encode gzip zstd
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Cache-Control "public, max-age=31536000, immutable"
}
handle /health {
respond "ok" 200
}
handle_errors {
rewrite * /404.html
file_server
}
}
CADDYFILE
RUN chown -R app:app /srv
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/health || exit 1
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile"]

50
docs-site/Makefile Normal file
View File

@ -0,0 +1,50 @@
include .env
export
COMPOSE_BASE := docker compose -f docker-compose.yml -f docker-compose.$(MODE).yml
.PHONY: dev prod build logs stop clean restart status help
## dev: start in dev mode with HMR and follow logs
dev:
@echo "Starting dev on $(DOMAIN) ..."
MODE=dev docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d
MODE=dev docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f
## prod: build and start in production mode
prod:
@echo "Deploying prod on $(DOMAIN) ..."
MODE=prod docker compose -f docker-compose.yml -f docker-compose.prod.yml up --build -d
MODE=prod docker compose -f docker-compose.yml -f docker-compose.prod.yml logs --tail=40
## build: build containers using current MODE from .env
build:
$(COMPOSE_BASE) build
## logs: follow container logs
logs:
$(COMPOSE_BASE) logs -f
## stop: stop containers
stop:
$(COMPOSE_BASE) down
## down: alias for stop
down: stop
## restart: rebuild and restart
restart: stop
$(COMPOSE_BASE) up --build -d
$(COMPOSE_BASE) logs --tail=20
## clean: stop, remove volumes and local images
clean:
$(COMPOSE_BASE) down -v --rmi local
## status: show running container status
status:
$(COMPOSE_BASE) ps
## help: show available targets
help:
@grep '^## ' Makefile | sed 's/^## //' | column -t -s ':'

View File

@ -0,0 +1,79 @@
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
site: 'https://spicebook-docs.warehack.ing',
telemetry: false,
devToolbar: { enabled: false },
integrations: [
starlight({
title: 'SpiceBook',
tagline: 'Circuit simulation notebooks',
social: [
{ icon: 'external', label: 'SpiceBook', href: 'https://spicebook.warehack.ing' },
{ icon: 'external', label: 'mcltspice', href: 'https://mcltspice.warehack.ing' },
],
customCss: ['./src/styles/custom.css'],
expressiveCode: {
themes: ['dracula', 'github-light'],
},
sidebar: [
{
label: 'Getting Started',
items: [
{ label: 'What is SpiceBook?', slug: '' },
{ label: 'Quick Start', slug: 'getting-started/quick-start' },
{ label: 'Editor Basics', slug: 'getting-started/editor-basics' },
],
},
{
label: 'Simulation',
items: [
{ label: 'Engines', slug: 'simulation/engines' },
{ label: 'Running Simulations', slug: 'simulation/running' },
{ label: 'Waveform Viewer', slug: 'simulation/waveforms' },
],
},
{
label: 'Schematics',
items: [
{ label: 'Auto-Generated Diagrams', slug: 'schematics/auto-generated' },
{ label: 'Color-Coded Resistors', slug: 'schematics/resistor-colors' },
],
},
{
label: 'Embedding',
items: [
{ label: 'Embed a Notebook', slug: 'embedding/embed-notebook' },
{ label: 'Theme Sync', slug: 'embedding/theme-sync' },
],
},
{
label: 'API',
collapsed: false,
items: [
{ label: 'REST API Overview', slug: 'api/overview' },
{ label: 'Notebooks', slug: 'api/notebooks' },
{ label: 'Cells', slug: 'api/cells' },
{ label: 'Simulation', slug: 'api/simulation' },
{ label: 'Waveforms & Schematics', slug: 'api/waveforms-schematics' },
],
},
],
}),
],
vite: {
plugins: [tailwindcss()],
server: {
host: '0.0.0.0',
...(process.env.VITE_HMR_HOST && {
hmr: {
host: process.env.VITE_HMR_HOST,
protocol: 'wss',
clientPort: 443,
},
}),
},
},
});

View File

@ -0,0 +1,19 @@
services:
docs:
environment:
- VITE_HMR_HOST=${DOMAIN}
volumes:
- ./src:/app/src
- ./public:/app/public
- ./astro.config.mjs:/app/astro.config.mjs
labels:
caddy.reverse_proxy: "{{upstreams 4321}}"
# HMR WebSocket support -- keeps Vite hot-reload alive through Caddy
caddy.reverse_proxy.flush_interval: "-1"
caddy.reverse_proxy.transport: http
caddy.reverse_proxy.transport.read_timeout: "0"
caddy.reverse_proxy.transport.write_timeout: "0"
caddy.reverse_proxy.transport.keepalive: "5m"
caddy.reverse_proxy.transport.keepalive_idle_conns: "10"
caddy.reverse_proxy.stream_timeout: "24h"
caddy.reverse_proxy.stream_close_delay: "5s"

View File

@ -0,0 +1,15 @@
services:
docs:
labels:
caddy.reverse_proxy: "{{upstreams 8080}}"
caddy.header.Cache-Control: "public, max-age=31536000, immutable"
caddy.header.X-Content-Type-Options: nosniff
caddy.header.X-Frame-Options: DENY
caddy.header.Referrer-Policy: strict-origin-when-cross-origin
security_opt:
- no-new-privileges:true
read_only: true
tmpfs:
- /tmp:noexec,nosuid,size=50m
- /config/caddy:size=10m
- /data/caddy:size=10m

View File

@ -0,0 +1,24 @@
services:
docs:
build:
context: .
dockerfile: Dockerfile
target: ${MODE:-dev}
container_name: spicebook-docs
restart: unless-stopped
environment:
- ASTRO_TELEMETRY_DISABLED=1
networks:
- caddy
labels:
caddy: ${DOMAIN}
caddy.encode: gzip
logging:
driver: json-file
options:
max-size: "5m"
max-file: "3"
networks:
caddy:
external: true

7988
docs-site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
docs-site/package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "spicebook-docs",
"type": "module",
"private": true,
"scripts": {
"postinstall": "node patches/fix-starlight-head.mjs",
"dev": "astro dev --host 0.0.0.0",
"build": "astro build",
"preview": "astro preview --host 0.0.0.0"
},
"dependencies": {
"astro": "^5.17.2",
"@astrojs/starlight": "^0.37.6",
"@astrojs/starlight-tailwind": "^4.0.2",
"@tailwindcss/vite": "^4.1.0",
"tailwindcss": "^4.1.0",
"sharp": "^0.33.0"
}
}

View File

@ -0,0 +1,35 @@
/**
* Patches @astrojs/starlight to handle undefined `data.head` in frontmatter.
*
* Starlight 0.37.x assumes docsSchema() defaults `head` to [] via Zod, but
* the content loader doesn't always apply this default, causing:
* "Cannot read properties of undefined (reading 'some')"
*
* This adds a nullish coalescing fallback: `data.head ?? []`
*/
import { readFileSync, writeFileSync } from 'node:fs';
const file = 'node_modules/@astrojs/starlight/utils/head.ts';
try {
let src = readFileSync(file, 'utf8');
const target = 'config.head, data.head)';
const replacement = 'config.head, data.head ?? [])';
if (src.includes(replacement)) {
console.log('[patch] head.ts already patched, skipping.');
process.exit(0);
}
if (!src.includes(target)) {
console.warn('[patch] Could not find target string in head.ts — Starlight may have been updated.');
process.exit(0);
}
src = src.replace(target, replacement);
writeFileSync(file, src, 'utf8');
console.log('[patch] Patched head.ts: data.head ?? []');
} catch (err) {
console.warn('[patch] Could not patch head.ts:', err.message);
}

View File

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://spicebook-docs.warehack.ing/sitemap-index.xml

View File

@ -0,0 +1,7 @@
import { defineCollection } from 'astro:content';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@ -0,0 +1,120 @@
---
title: Cells
description: Add, update, delete, and reorder cells within SpiceBook notebooks via the REST API.
---
import { Aside } from '@astrojs/starlight/components';
Cells are ordered elements within a notebook. Each cell has a `type` and `source` (text content). The API lets you manipulate cells individually without replacing the entire notebook.
## Cell Types
| Type | Description |
|------|-------------|
| `markdown` | Documentation text, rendered as formatted markdown |
| `spice` | SPICE netlist, executable via the simulation endpoints |
| `python` | Python code (execution planned for future release) |
| `schematic` | Auto-generated circuit diagram (read-only display) |
## Add Cell
```
POST /api/notebooks/{notebook_id}/cells
```
**Request body:**
```json
{
"type": "spice",
"source": "V1 1 0 DC 5\nR1 1 0 1k\n.op\n.end",
"after_cell_id": "cell-a1b2c3d4e5f6"
}
```
`after_cell_id` is optional — omit it to append the cell at the end.
**Response** `201`:
```json
{
"id": "cell-b2c3d4e5f6a7",
"type": "spice",
"source": "V1 1 0 DC 5\nR1 1 0 1k\n.op\n.end",
"outputs": []
}
```
## Update Cell
```
PUT /api/notebooks/{notebook_id}/cells/{cell_id}
```
**Request body:**
```json
{
"source": "V1 1 0 DC 10\nR1 1 0 2k\n.op\n.end",
"type": "spice"
}
```
Both fields are optional — only provided fields are updated. Changing `source` does not automatically re-run the simulation; call the [run endpoint](/api/simulation/#run-cell-in-notebook) to execute.
**Response** `200` — updated cell object.
## Delete Cell
```
DELETE /api/notebooks/{notebook_id}/cells/{cell_id}
```
**Response** `204` — no content.
## Reorder Cells
```
PUT /api/notebooks/{notebook_id}/cells/reorder
```
**Request body:**
```json
{
"cell_ids": ["cell-b2c3d4e5f6a7", "cell-a1b2c3d4e5f6"]
}
```
The array must include every cell ID in the notebook exactly once. This replaces the cell order entirely.
**Response** `200` — array of cell objects in the new order.
<Aside type="note">
Cell IDs are generated server-side and are stable — they don't change when cells are reordered or when other cells are added/removed.
</Aside>
## Cell Data Model
```json
{
"id": "cell-a1b2c3d4e5f6",
"type": "spice",
"source": "V1 1 0 DC 5\n.op\n.end",
"outputs": [
{
"output_type": "simulation_result",
"data": {
"success": true,
"waveform": { "..." },
"log": "...",
"error": null,
"elapsed_seconds": 0.3
},
"timestamp": "2026-02-13T18:35:00+00:00"
}
]
}
```
The `outputs` array contains results from previous simulation runs. Each entry includes a timestamp so you can track when the cell was last executed.

View File

@ -0,0 +1,149 @@
---
title: Notebooks
description: Create, read, update, delete, and compose SpiceBook notebooks via the REST API.
---
import { Aside } from '@astrojs/starlight/components';
## List Notebooks
```
GET /api/notebooks
```
Returns an array of notebook summaries:
```json
[
{
"id": "rc-low-pass-a1b2c3d4",
"title": "RC Low-Pass Filter",
"engine": "ngspice",
"tags": ["filter", "rc"],
"cell_count": 3,
"modified": "2026-02-13T18:30:00+00:00"
}
]
```
## Create Notebook
```
POST /api/notebooks
```
**Request body:**
```json
{
"title": "My Circuit",
"engine": "ngspice"
}
```
Both fields are optional. Defaults: title = `"Untitled Notebook"`, engine = `"ngspice"`. Supported engines: `"ngspice"`, `"ltspice"`.
**Response** `201` — the full notebook with an auto-generated `id`:
```json
{
"id": "my-circuit-f8e2a91b",
"spicebook_version": "2026-02-13",
"metadata": {
"title": "My Circuit",
"engine": "ngspice",
"tags": [],
"created": "2026-02-13T18:30:00+00:00",
"modified": "2026-02-13T18:30:00+00:00"
},
"cells": [
{
"id": "cell-a1b2c3d4e5f6",
"type": "markdown",
"source": "# My Circuit\n\nAdd SPICE cells below to begin simulating.",
"outputs": []
}
]
}
```
New notebooks start with a single markdown cell containing a heading.
## Get Notebook
```
GET /api/notebooks/{notebook_id}
```
**Response** `200` — full notebook object (same shape as the create response, without the top-level `id` wrapper).
## Update Notebook
```
PUT /api/notebooks/{notebook_id}
```
**Request body** — full notebook object. This replaces the entire notebook, including all cells and metadata.
**Response** `200` — the saved notebook.
## Delete Notebook
```
DELETE /api/notebooks/{notebook_id}
```
**Response** `204` — no content.
<Aside type="caution">
Only user-created notebooks can be deleted. Built-in example notebooks return a `403` error.
</Aside>
## Compose Notebook
```
POST /api/notebooks/compose
```
Creates a fully-populated notebook with multiple cells in a single call. This is the preferred way to create notebooks programmatically.
**Request body:**
```json
{
"title": "RC Low-Pass Filter",
"engine": "ngspice",
"tags": ["filter", "rc", "analog"],
"cells": [
{
"type": "markdown",
"source": "# RC Low-Pass Filter\n\nA simple first-order low-pass filter."
},
{
"type": "spice",
"source": "V1 in 0 AC 1\nR1 in out 1k\nC1 out 0 1u\n.ac dec 100 1 1meg\n.end"
},
{
"type": "markdown",
"source": "## Analysis\n\nThe -3dB cutoff is at f = 1/(2*pi*R*C) = 159 Hz."
}
],
"run": false
}
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `title` | string | `"Untitled Notebook"` | Notebook title |
| `engine` | string | `"ngspice"` | Simulation engine |
| `tags` | string[] | `[]` | Searchable tags |
| `cells` | object[] | (required) | Cells to create, each with `type` and `source` |
| `run` | boolean | `false` | If `true`, execute each SPICE cell after creation |
**Response** `201` — same shape as `POST /api/notebooks`.
When `run` is `true`, each SPICE cell is executed sequentially using the notebook's engine. Simulation results are stored in each cell's `outputs` array. Non-SPICE cells are unaffected.
<Aside type="tip">
The compose endpoint is what [mcltspice](https://mcltspice.warehack.ing) uses to publish simulation results as SpiceBook notebooks. Pass `run: true` to auto-execute all SPICE cells on creation.
</Aside>

View File

@ -0,0 +1,66 @@
---
title: REST API Overview
description: SpiceBook REST API basics — base URL, response format, and endpoint structure.
---
import { LinkCard, CardGrid, Aside } from '@astrojs/starlight/components';
SpiceBook exposes a REST API for programmatic access to notebooks, simulation, and rendering. All endpoints accept and return JSON unless noted otherwise.
## Base URL
```
https://spicebook.warehack.ing/api
```
All endpoint paths below are relative to this base.
## Authentication
None. The API is open — no API keys or tokens are required. This makes it straightforward to integrate with scripts, CI pipelines, or AI assistants like [mcltspice](https://mcltspice.warehack.ing).
## Response Format
Successful responses return JSON with the appropriate HTTP status code (`200`, `201`, or `204`). Error responses include a detail message:
```json
{
"detail": "Notebook not found"
}
```
Common status codes:
| Code | Meaning |
|------|---------|
| `200` | Success |
| `201` | Created (new notebook or cell) |
| `204` | Deleted (no response body) |
| `404` | Resource not found |
| `422` | Validation error (malformed request body) |
| `500` | Internal server error (simulation crash, etc.) |
## Health Check
```
GET /health
```
Returns the API version and status:
```json
{"status": "ok", "version": "2026.02.13"}
```
## Endpoint Groups
<CardGrid>
<LinkCard title="Notebooks" description="Create, read, update, delete, and compose notebooks." href="/api/notebooks/" />
<LinkCard title="Cells" description="Add, update, delete, and reorder cells within notebooks." href="/api/cells/" />
<LinkCard title="Simulation" description="Run SPICE netlists standalone or within notebook cells." href="/api/simulation/" />
<LinkCard title="Waveforms & Schematics" description="Generate SVG plots and circuit diagrams." href="/api/waveforms-schematics/" />
</CardGrid>
<Aside type="tip">
The SpiceBook backend also serves interactive API docs at [`/docs`](https://spicebook.warehack.ing/docs) (Swagger UI) and [`/openapi.json`](https://spicebook.warehack.ing/openapi.json) (OpenAPI spec).
</Aside>

View File

@ -0,0 +1,113 @@
---
title: Simulation
description: Run SPICE netlists standalone or within notebook cells via the SpiceBook REST API.
---
import { Aside } from '@astrojs/starlight/components';
## Run Standalone Simulation
```
POST /api/simulate
```
Run a SPICE netlist without creating or modifying a notebook. Useful for one-off simulations or integration testing.
**Request body:**
```json
{
"netlist": "V1 1 0 DC 5\nR1 1 2 1k\nR2 2 0 2k\n.op\n.end",
"engine": "ngspice"
}
```
**Response** `200`:
```json
{
"success": true,
"waveform": {
"variables": [
{"name": "v(1)", "type": "voltage"},
{"name": "v(2)", "type": "voltage"}
],
"points": 1,
"x_data": [0.0],
"y_data": {"v(1)": [5.0], "v(2)": [3.333]},
"x_type": "time",
"is_complex": false,
"y_magnitude_db": null,
"y_phase_deg": null
},
"log": "ngspice output...",
"error": null,
"elapsed_seconds": 0.42
}
```
## Run Cell in Notebook
```
POST /api/notebooks/{notebook_id}/cells/{cell_id}/run
```
No request body. Uses the cell's `source` as the netlist and the notebook's `engine` setting.
**Response** `200` — same `SimulationResponse` shape as standalone. The cell's `outputs` array is updated in the saved notebook.
## SimulationResponse
| Field | Type | Description |
|-------|------|-------------|
| `success` | boolean | Whether the simulation completed without errors |
| `waveform` | WaveformData \| null | Simulation results (null if simulation failed) |
| `log` | string | Raw simulator output (stdout/stderr) |
| `error` | string \| null | Error message if simulation failed |
| `elapsed_seconds` | number | Wall-clock simulation time |
## WaveformData
```json
{
"variables": [{"name": "v(out)", "type": "voltage"}],
"points": 100,
"x_data": [0.0, 0.001, ...],
"y_data": {"v(out)": [0.0, 0.5, ...]},
"x_type": "time",
"is_complex": false,
"y_magnitude_db": null,
"y_phase_deg": null
}
```
| Field | Type | Description |
|-------|------|-------------|
| `variables` | array | Signal names and types (voltage, current) |
| `points` | integer | Number of data points |
| `x_data` | number[] | Horizontal axis values (time or frequency) |
| `y_data` | object | Signal name → value array mapping |
| `x_type` | string | `"time"` or `"frequency"` |
| `is_complex` | boolean | `true` for AC analysis |
| `y_magnitude_db` | object \| null | Per-signal magnitude in dB (AC only) |
| `y_phase_deg` | object \| null | Per-signal phase in degrees (AC only) |
<Aside type="note">
For AC analysis (`is_complex: true`), use `y_magnitude_db` and `y_phase_deg` for Bode plots rather than `y_data`. The `x_type` will be `"frequency"` and `x_data` contains frequency values in Hz.
</Aside>
## Error Handling
When a simulation fails (syntax error, convergence failure, missing model), the response still returns `200` but with `success: false`:
```json
{
"success": false,
"waveform": null,
"log": "Error: unknown subcircuit...",
"error": "Simulation failed: unknown subcircuit 'LM741'",
"elapsed_seconds": 0.1
}
```
Check the `log` field for the full simulator output — it often contains line numbers and detailed diagnostics that aren't in the `error` summary.

View File

@ -0,0 +1,129 @@
---
title: Waveforms & Schematics
description: Generate SVG waveform plots and circuit schematic diagrams via the SpiceBook REST API.
---
import { Aside } from '@astrojs/starlight/components';
## Generate Waveform SVG (JSON-Wrapped)
```
POST /api/waveforms/svg
```
Renders waveform data as an SVG plot, returned inside a JSON wrapper.
**Request body:**
```json
{
"waveform": { "...WaveformData from simulation response..." },
"title": "Output Voltage",
"width": 800,
"height": 500,
"signals": ["v(2)"]
}
```
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `waveform` | WaveformData | (required) | Simulation result data |
| `title` | string | `""` | Plot title |
| `width` | integer | `800` | SVG width in pixels |
| `height` | integer | `500` | SVG height in pixels |
| `signals` | string[] | all | Signals to include in the plot |
**Response** `200`:
```json
{
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" ...>...</svg>"
}
```
## Generate Waveform SVG (Raw)
```
POST /api/waveforms/svg/raw
```
Same request body as above, but returns the raw SVG string with `Content-Type: image/svg+xml`. Useful for piping directly to a file:
```bash
curl -X POST https://spicebook.warehack.ing/api/waveforms/svg/raw \
-H "Content-Type: application/json" \
-d '{
"waveform": { ...waveform data... },
"title": "DC Sweep",
"signals": ["v(2)"]
}' \
-o plot.svg
```
## Generate Schematic
```
POST /api/notebooks/{notebook_id}/cells/{cell_id}/schematic
```
No request body. The cell must be type `spice`. Parses the cell's netlist and renders a circuit diagram.
**Response** `200`:
```json
{
"svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" ...>...</svg>",
"success": true,
"error": null,
"component_map": {
"R1": "resistor",
"V1": "voltage_source",
"C1": "capacitor"
}
}
```
| Field | Type | Description |
|-------|------|-------------|
| `svg` | string | SVG markup of the circuit diagram |
| `success` | boolean | Whether generation succeeded |
| `error` | string \| null | Error message if generation failed |
| `component_map` | object | Component reference → type mapping |
The SVG includes standard IEEE symbols for components. Resistors with parseable values (0.01Ω1GΩ) render with [color-coded bands](/schematics/resistor-colors/) on the zigzag symbol.
<Aside type="tip">
Both waveform and schematic SVGs are self-contained — they include all styling inline and can be saved as standalone `.svg` files or embedded directly in HTML.
</Aside>
## End-to-End Example
Simulate a circuit and generate both a waveform plot and schematic:
```bash
# 1. Create a notebook with a SPICE cell and run it
RESPONSE=$(curl -s -X POST https://spicebook.warehack.ing/api/notebooks/compose \
-H "Content-Type: application/json" \
-d '{
"title": "RC Filter",
"cells": [
{"type": "spice", "source": "V1 in 0 AC 1\nR1 in out 1k\nC1 out 0 1u\n.ac dec 100 1 1meg\n.end"}
],
"run": true
}')
# 2. Extract IDs
NB_ID=$(echo $RESPONSE | jq -r '.id')
CELL_ID=$(echo $RESPONSE | jq -r '.cells[0].id')
# 3. Generate schematic
curl -s -X POST "https://spicebook.warehack.ing/api/notebooks/$NB_ID/cells/$CELL_ID/schematic" \
| jq -r '.svg' > schematic.svg
# 4. Generate waveform plot from simulation results
WAVEFORM=$(echo $RESPONSE | jq '.cells[0].outputs[0].data.waveform')
curl -s -X POST https://spicebook.warehack.ing/api/waveforms/svg/raw \
-H "Content-Type: application/json" \
-d "{\"waveform\": $WAVEFORM, \"title\": \"Bode Plot\"}" \
-o bode.svg
```

View File

@ -0,0 +1,54 @@
---
title: Embed a Notebook
description: Embed interactive SpiceBook notebooks on any webpage with a simple iframe snippet.
---
import { Aside, Code } from '@astrojs/starlight/components';
Any SpiceBook notebook can be embedded on a third-party page via iframe. No API key or authentication is required — embeds are public and read-only, but viewers can still run simulations and see results.
## Embed URL
```
https://spicebook.warehack.ing/embed/{notebook_id}?theme=dark
```
| Parameter | Values | Default | Description |
|-----------|--------|---------|-------------|
| `theme` | `dark`, `light` | `dark` | Initial color theme |
## iframe Snippet
```html
<iframe
src="https://spicebook.warehack.ing/embed/{notebook_id}?theme=dark"
width="100%" height="600"
style="border: 1px solid #334155; border-radius: 8px;"
allow="clipboard-write"
></iframe>
```
Replace `{notebook_id}` with your notebook's ID (visible in the browser URL bar when editing, or returned by the API).
<Aside type="tip">
The easiest way to get the embed snippet is from SpiceBook itself — open any notebook, click the **Embed** button in the toolbar, and copy the pre-filled iframe code.
</Aside>
## What Viewers Can Do
Embedded notebooks render in a read-only mode with working simulation:
- **Read** markdown cells and SPICE netlist source
- **Run** SPICE cells and see waveform results
- **View** previously-generated schematics and plots
- **Copy** code from cells via clipboard
Viewers cannot edit cell content, add or delete cells, or change notebook metadata.
## Sizing
The iframe adapts to the width of its container. Set `width="100%"` for responsive layouts. The `height` should be tall enough to show the notebook content without excessive scrolling — `600px` works well for notebooks with 2-4 cells.
## CORS
The SpiceBook backend allows framing from any origin by default. If your deployment uses a restricted `CORS_EXTRA_ORIGINS` configuration, ensure the embedding domain is included.

View File

@ -0,0 +1,77 @@
---
title: Theme Sync
description: Control an embedded SpiceBook notebook's theme from the parent page using the postMessage API.
---
import { Aside } from '@astrojs/starlight/components';
Embedded notebooks support runtime theme switching via the browser's `postMessage` API. This lets the parent page keep the embed's appearance in sync with its own dark/light mode toggle.
## Setting the Initial Theme
Use the `theme` query parameter on the embed URL:
```
https://spicebook.warehack.ing/embed/{id}?theme=light
```
This sets the theme when the iframe first loads. To change it after load, use `postMessage`.
## Switching Themes at Runtime
Send a message to the iframe's content window:
```javascript
const iframe = document.querySelector('iframe');
iframe.contentWindow.postMessage(
{ type: 'spicebook-theme', theme: 'light' },
'*'
);
```
### Message Format
| Field | Type | Values | Description |
|-------|------|--------|-------------|
| `type` | string | `"spicebook-theme"` | Message identifier (required) |
| `theme` | string | `"dark"`, `"light"` | Target theme |
Messages with any other `type` value are ignored by the embed.
## Example: Sync with Parent Toggle
A common pattern is to listen for your page's theme change and forward it to the embed:
```javascript
// Assuming your page toggles a data-theme attribute on <html>
const observer = new MutationObserver(() => {
const theme = document.documentElement.dataset.theme || 'dark';
iframe.contentWindow.postMessage(
{ type: 'spicebook-theme', theme },
'*'
);
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
```
<Aside type="note">
The embed responds to theme messages immediately — there's no transition delay. The entire UI (backgrounds, text, code blocks, waveform plots) switches to the requested theme.
</Aside>
## Security
The `postMessage` call uses `'*'` as the target origin for simplicity. If you want stricter security, specify the SpiceBook origin:
```javascript
iframe.contentWindow.postMessage(
{ type: 'spicebook-theme', theme: 'dark' },
'https://spicebook.warehack.ing'
);
```
The embed only acts on messages with `type: 'spicebook-theme'` — all other messages are silently ignored.

View File

@ -0,0 +1,59 @@
---
title: Editor Basics
description: Cell types, toolbar actions, and keyboard shortcuts in the SpiceBook notebook editor.
---
import { Aside } from '@astrojs/starlight/components';
A SpiceBook notebook is a vertical sequence of cells. Each cell has a type that determines how it behaves and renders.
## Cell Types
| Type | Purpose | Editor |
|------|---------|--------|
| **Markdown** | Documentation, headings, LaTeX-style math | Rich text with live preview |
| **SPICE** | Circuit netlists for simulation | Code editor with SPICE syntax highlighting |
| **Python** | Computation and scripting | Code editor (execution planned) |
| **Schematic** | Auto-generated circuit diagrams | Read-only SVG display |
Markdown cells render immediately as you type. SPICE cells require you to click **Run** to execute the netlist and see simulation results.
## Toolbar
The notebook toolbar sits at the top of the editor and provides:
- **Run All** — executes every SPICE cell in order, top to bottom
- **Engine selector** — switch between ngspice and LTspice for the entire notebook
- **Embed** — opens a popover with a ready-to-copy iframe snippet for sharing
- **Tags** — add searchable tags to organize notebooks on the homepage
Each individual SPICE cell also has its own run and schematic buttons in the cell toolbar.
## Adding and Reordering Cells
Click **+ Add Cell** between any two cells to insert a new one. The dropdown lets you choose the cell type.
Cells can be reordered by dragging, or programmatically via the [reorder API endpoint](/api/cells/#reorder-cells).
To delete a cell, use the cell menu (three dots) and select **Delete**.
## Code Editor
SPICE cells use a code editor with:
- **Syntax highlighting** for SPICE netlist keywords (`.tran`, `.ac`, `.op`, etc.)
- **Line numbers** for referencing specific netlist lines
- **Auto-indentation** following SPICE conventions
<Aside type="note">
The editor uses the SpiceBook dark theme by default — slate backgrounds with a blue cursor. The color scheme matches the rest of the application.
</Aside>
## Notebook Metadata
Every notebook tracks:
- **Title** — displayed in the gallery and browser tab
- **Engine** — `ngspice` or `ltspice`, applies to all SPICE cells
- **Tags** — free-form labels for filtering in the notebook gallery
- **Created / Modified** — ISO-8601 timestamps, set automatically

View File

@ -0,0 +1,67 @@
---
title: Quick Start
description: Create a notebook, write a SPICE netlist, run a simulation, and see waveforms — in under five minutes.
---
import { Steps, Aside } from '@astrojs/starlight/components';
This guide walks through creating your first SpiceBook notebook. You'll build a voltage divider, simulate it, and see the results — all in the browser.
<Steps>
1. **Open SpiceBook**
Go to [spicebook.warehack.ing](https://spicebook.warehack.ing) and click **New Notebook**. You'll get a blank notebook with a single markdown cell.
2. **Add a SPICE cell**
Click the **+ Add Cell** button and select **SPICE**. Paste this netlist:
```
* Voltage Divider
V1 1 0 DC 5
R1 1 2 1k
R2 2 0 2k
.op
.end
```
This creates a 5V source with two resistors. The `.op` directive runs a DC operating point analysis — it computes the steady-state voltages at every node.
3. **Run the simulation**
Click the **Run** button on the SPICE cell (or use the keyboard shortcut). The backend sends the netlist to ngspice and returns the results:
- `V(1) = 5.0V` — the supply voltage
- `V(2) = 3.333V` — the divided voltage: `5 × 2k / (1k + 2k)`
4. **Try a transient analysis**
Replace the netlist with a time-domain simulation:
```
* RC Charging Curve
V1 1 0 PULSE(0 5 0 1n 1n 5m 10m)
R1 1 2 1k
C1 2 0 1u
.tran 1u 20m
.end
```
Run it again. This time you'll see a waveform plot showing the capacitor voltage rising exponentially toward 5V with a time constant of `RC = 1ms`.
5. **Generate a schematic**
Click the **Schematic** button on any SPICE cell. SpiceBook parses the netlist and draws a circuit diagram as an SVG — resistors render as IEEE zigzag symbols with color-coded bands matching their values.
</Steps>
<Aside type="tip">
You can also create notebooks programmatically via the [compose endpoint](/api/notebooks/#compose-notebook), which accepts multiple cells and can auto-run SPICE cells in a single API call.
</Aside>
## Next Steps
- **[Editor Basics](/getting-started/editor-basics/)** — cell types, toolbar actions, keyboard shortcuts
- **[Engines](/simulation/engines/)** — choosing between ngspice and LTspice
- **[Waveform Viewer](/simulation/waveforms/)** — interactive plots, oscilloscope mode, AC bode plots

View File

@ -0,0 +1,50 @@
---
title: What is SpiceBook?
description: Notebook interface for SPICE circuit simulation — write netlists, simulate with ngspice or LTspice, and visualize waveforms in a single document.
template: splash
hero:
tagline: Write SPICE netlists, simulate, and visualize waveforms — all in one document.
actions:
- text: Quick Start
link: /getting-started/quick-start/
icon: right-arrow
variant: primary
- text: Open SpiceBook
link: https://spicebook.warehack.ing
icon: external
variant: minimal
---
import { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';
## What SpiceBook Does
SpiceBook is a notebook interface for SPICE circuit simulation. Each notebook is a sequence of cells — markdown for documentation, SPICE netlists for circuits, and auto-generated schematics and waveform plots for visualization.
You write a netlist, run it against ngspice or LTspice, and see the results inline. No file management, no separate plotting tools, no context switching between editor and simulator.
<CardGrid>
<Card title="Dual Engines" icon="setting">
Choose ngspice (built-in) or LTspice (via [mcltspice](https://mcltspice.warehack.ing)) per notebook. Same netlist format, different solver characteristics.
</Card>
<Card title="Waveform Plots" icon="line-chart">
Transient, DC sweep, and AC analysis results render as interactive SVG plots with an oscilloscope display mode.
</Card>
<Card title="Auto Schematics" icon="puzzle">
SPICE netlists are parsed into circuit diagrams automatically — resistors even render with IEEE zigzag symbols and 4-band color codes.
</Card>
<Card title="Embeddable" icon="laptop">
Any notebook can be embedded on a third-party page via iframe. Viewers can run simulations directly in the embed.
</Card>
</CardGrid>
## How It Fits Together
SpiceBook is part of the [warehack.ing](https://warehack.ing) project family. It pairs with [mcltspice](https://mcltspice.warehack.ing), an MCP server that gives AI assistants access to LTspice simulation — notebooks created by mcltspice are viewable and editable in SpiceBook.
<CardGrid>
<LinkCard title="Quick Start" description="Create a notebook and run your first simulation." href="/getting-started/quick-start/" />
<LinkCard title="REST API" description="Programmatic access to notebooks, simulation, and rendering." href="/api/overview/" />
<LinkCard title="Embedding" description="Embed interactive notebooks on any page." href="/embedding/embed-notebook/" />
<LinkCard title="Engines" description="ngspice vs LTspice — differences and how to choose." href="/simulation/engines/" />
</CardGrid>

View File

@ -0,0 +1,62 @@
---
title: Auto-Generated Diagrams
description: How SpiceBook converts SPICE netlists into circuit diagram SVGs automatically.
---
import { Aside } from '@astrojs/starlight/components';
SpiceBook can generate a schematic diagram from any SPICE cell. Click the **Schematic** button in the cell toolbar, and the backend parses the netlist into a circuit diagram rendered as an inline SVG.
## How It Works
The schematic generator:
1. **Parses the netlist** to identify components (resistors, capacitors, inductors, voltage sources, current sources) and their node connections
2. **Determines layout** by walking the circuit graph and positioning components to minimize wire crossings
3. **Renders SVG** with standard electrical engineering symbols — IEEE zigzag for resistors, parallel plates for capacitors, coil for inductors, ± symbols for sources
4. **Labels components** with their reference designator (`R1`, `C1`, `V1`) and value
The result is a clean, readable diagram that matches what you'd draw by hand.
## Supported Components
| Component | Netlist Prefix | Symbol |
|-----------|---------------|--------|
| Resistor | `R` | IEEE zigzag (with optional color bands) |
| Capacitor | `C` | Parallel plates |
| Inductor | `L` | Coil |
| Voltage source | `V` | Circle with ± |
| Current source | `I` | Circle with arrow |
| Ground | node `0` | Standard ground symbol |
## Component Map
The schematic endpoint returns a `component_map` alongside the SVG, listing each component and its type:
```json
{
"svg": "<svg ...>...</svg>",
"success": true,
"component_map": {
"R1": "resistor",
"V1": "voltage_source",
"C1": "capacitor"
}
}
```
This is useful for programmatic tools that need to know what's in the circuit without parsing the SVG.
## API Access
Generate schematics programmatically:
```bash
curl -X POST https://spicebook.warehack.ing/api/notebooks/{id}/cells/{cell_id}/schematic
```
The cell must be type `spice`. No request body is needed — the cell's `source` netlist is used directly. See the [Waveforms & Schematics API reference](/api/waveforms-schematics/) for the full response format.
<Aside type="note">
Schematic generation works for both ngspice and LTspice netlists. The generator parses the netlist text directly rather than relying on engine-specific features, so it works identically with either engine.
</Aside>

View File

@ -0,0 +1,71 @@
---
title: Color-Coded Resistors
description: IEEE zigzag resistor symbols with 4-band color codes rendered directly on schematic diagrams.
---
import { Aside } from '@astrojs/starlight/components';
When SpiceBook generates a schematic, resistors with parseable values render as IEEE zigzag symbols with color-coded segments matching the standard 4-band resistor color code.
## How It Works
A resistor value like `1k` (1000Ω) is decomposed into:
- **1st significant digit:** 1 → Brown
- **2nd significant digit:** 0 → Black
- **Multiplier:** ×100 → Red
- **Tolerance:** ±5% → Gold (default)
These colors are applied directly to the zigzag segments in the SVG, mimicking how a physical resistor looks.
## Band-to-Segment Mapping
The IEEE zigzag symbol has 7 sub-segments. Each is assigned a color role:
| Segment | Role | Color |
|---------|------|-------|
| 0 (entry) | Wire | Default wire color |
| 1 | 1st significant digit | Color band |
| 2 | 2nd significant digit | Color band |
| 3 | Multiplier | Color band |
| 4 (gap) | Physical spacing | Default wire color |
| 5 | Tolerance | Color band |
| 6 (exit) | Wire | Default wire color |
The entry/exit segments and the gap before the tolerance band use the default wire color. This mimics the physical spacing on a real resistor that indicates reading direction — you read bands from the end closest to the first band.
## Color Code Reference
| Digit | Color | Hex |
|-------|-------|-----|
| 0 | Black | `#1a1a1a` |
| 1 | Brown | `#8B4513` |
| 2 | Red | `#cc0000` |
| 3 | Orange | `#ff6600` |
| 4 | Yellow | `#cccc00` |
| 5 | Green | `#006400` |
| 6 | Blue | `#0000cc` |
| 7 | Violet | `#7b1fa2` |
| 8 | Gray | `#555555` |
| 9 | White | `#e0e0e0` |
**Multiplier bands** use the same color mapping (Black = ×1, Brown = ×10, Red = ×100, etc.).
**Tolerance:** Gold (±5%) is the default when no tolerance is specified.
## Value Range
Color-coded rendering applies to resistor values between **0.01Ω and 1GΩ**. Outside this range, or for parametric values (e.g., `{R_val}`), the resistor falls back to a standard monochrome zigzag.
<Aside type="tip">
SpiceBook also has a dedicated [Resistor Color Codes reference page](https://spicebook.warehack.ing/reference/resistor-colors) in the main application where you can look up any value interactively.
</Aside>
## Example
A `4.7k` resistor renders with:
- **Yellow** (4) — 1st digit
- **Violet** (7) — 2nd digit
- **Red** (×100) — multiplier
- **Gold** (±5%) — tolerance
The result is immediately recognizable to anyone who's read resistor bands by hand — the schematic tells you both the circuit topology and the component values at a glance.

View File

@ -0,0 +1,60 @@
---
title: Engines
description: ngspice vs LTspice — differences, availability, and how to choose a simulation engine in SpiceBook.
---
import { Aside } from '@astrojs/starlight/components';
SpiceBook supports two SPICE simulation engines. Each notebook is bound to one engine, selected when you create it (or changed later via the toolbar).
## ngspice
The default engine. ngspice is an open-source circuit simulator that runs natively on the SpiceBook backend — no additional setup required.
**Strengths:**
- Always available — built into the backend container
- Broad analysis support: `.op`, `.dc`, `.tran`, `.ac`, `.noise`, `.pz`
- Well-documented netlist syntax with extensive model libraries
- Fast for small-to-medium circuits
**Typical use:** General-purpose simulation, educational circuits, quick prototyping.
## LTspice
Available when the SpiceBook backend is configured with [mcltspice](https://mcltspice.warehack.ing), an MCP server that automates LTspice via Wine on Linux.
**Strengths:**
- Proprietary solver with strong convergence for switching circuits
- Extensive built-in component library (power semiconductors, op-amps, regulators)
- Better performance on large circuits with many timesteps
- Tight `.meas` directive support for automated measurements
**Typical use:** Power electronics, switching regulators, circuits using manufacturer models.
<Aside type="caution">
LTspice availability depends on the server configuration. If mcltspice isn't running, selecting "ltspice" as the engine will return an error when you try to simulate. The engine selector in the toolbar shows which engines are available.
</Aside>
## Differences That Matter
| Feature | ngspice | LTspice |
|---------|---------|---------|
| Availability | Always | Requires mcltspice |
| Operating point (`.op`) | Yes | Yes |
| Transient (`.tran`) | Yes | Yes |
| AC sweep (`.ac`) | Yes | Yes |
| Convergence on switchers | Fair | Excellent |
| Built-in device models | Community libs | Extensive vendor libs |
| Output format | Raw binary | Raw binary |
Both engines produce the same `WaveformData` structure in SpiceBook — the waveform viewer, SVG plots, and schematic generation work identically regardless of which engine ran the simulation.
## Choosing an Engine
For most circuits, ngspice works well and requires no configuration. Reach for LTspice when you need:
- A specific manufacturer model that ships with LTspice (LT1234, LTC3780, etc.)
- Better convergence on circuits with fast switching edges
- `.meas` directives for automated waveform measurements
You can switch engines on an existing notebook at any time — the netlist syntax is largely compatible. Re-run cells after switching to regenerate results with the new engine.

View File

@ -0,0 +1,118 @@
---
title: Running Simulations
description: How to execute SPICE cells, interpret results, and use Run All for multi-cell notebooks.
---
import { Aside } from '@astrojs/starlight/components';
## Running a Single Cell
Every SPICE cell has a **Run** button in its toolbar. Clicking it sends the cell's netlist to the backend, which:
1. Writes the netlist to a temp file
2. Invokes the selected engine (ngspice or LTspice)
3. Parses the raw output into structured `WaveformData`
4. Returns results to the browser and saves them in the cell's `outputs` array
Results appear inline beneath the cell — either a data table (for `.op`) or a waveform plot (for `.tran`, `.dc`, `.ac`).
## Run All
The toolbar's **Run All** button executes every SPICE cell in the notebook from top to bottom, sequentially. Each cell's results are saved before the next cell runs.
This is useful for notebooks where later cells depend on understanding earlier results, or when you want to regenerate all outputs after changing the engine.
## Analysis Types
The analysis directive in your netlist determines what kind of results you get:
### DC Operating Point (`.op`)
```
V1 1 0 DC 5
R1 1 2 1k
R2 2 0 2k
.op
.end
```
Returns a single set of node voltages and branch currents. Displayed as a table.
### Transient (`.tran`)
```
V1 1 0 PULSE(0 5 0 1n 1n 5u 10u)
R1 1 2 1k
C1 2 0 100n
.tran 10n 50u
.end
```
Returns time-series data. Displayed as an XY waveform plot with time on the horizontal axis.
### DC Sweep (`.dc`)
```
V1 1 0 DC 5
R1 1 2 1k
R2 2 0 2k
.dc V1 0 10 0.5
.end
```
Sweeps a source value and returns node voltages at each step. Displayed as a waveform plot with the swept parameter on the horizontal axis.
### AC Analysis (`.ac`)
```
V1 in 0 AC 1
R1 in out 1k
C1 out 0 1u
.ac dec 100 1 1meg
.end
```
Returns complex frequency-domain data. Displayed as a Bode plot — magnitude in dB and phase in degrees versus frequency on a log scale.
<Aside type="tip">
AC analysis results include `y_magnitude_db` and `y_phase_deg` fields in the waveform data. The viewer automatically switches to Bode plot mode when these are present.
</Aside>
## Output Format
Every simulation returns a `SimulationResponse`:
```json
{
"success": true,
"waveform": {
"variables": [{"name": "v(out)", "type": "voltage"}],
"points": 100,
"x_data": [0.0, 0.001, ...],
"y_data": {"v(out)": [0.0, 0.5, ...]},
"x_type": "time",
"is_complex": false
},
"log": "ngspice output...",
"error": null,
"elapsed_seconds": 0.42
}
```
If the simulation fails (syntax error, convergence failure), `success` is `false` and `error` contains the diagnostic message. The simulator log is always available in the `log` field for debugging.
## Engineering Notation
SPICE uses its own suffix convention for component values — be aware that **`M` means milli** (10⁻³), not mega. Use `meg` for 10⁶.
| Suffix | Multiplier | Example |
|--------|-----------|---------|
| `T` | 10¹² | `1T` |
| `G` | 10⁹ | `2.2G` |
| `meg` | 10⁶ | `1meg` |
| `k` | 10³ | `4.7k` |
| `m` | 10⁻³ | `10m` |
| `u` | 10⁻⁶ | `100u` |
| `n` | 10⁻⁹ | `47n` |
| `p` | 10⁻¹² | `10p` |
| `f` | 10⁻¹⁵ | `1f` |

View File

@ -0,0 +1,76 @@
---
title: Waveform Viewer
description: Interactive waveform plots, oscilloscope display mode, and AC Bode plots in SpiceBook.
---
import { Aside } from '@astrojs/starlight/components';
After running a simulation, SpiceBook renders the results as interactive SVG plots directly beneath the SPICE cell. The viewer adapts to the analysis type automatically.
## Plot Types
### Time-Domain (Transient / DC Sweep)
Transient (`.tran`) and DC sweep (`.dc`) results display as standard XY plots:
- **X-axis:** time (seconds) or swept parameter value
- **Y-axis:** node voltages and branch currents
- **Multiple traces** are overlaid with distinct colors from an 8-color palette
Each trace is labeled with its signal name (`v(out)`, `i(R1)`, etc.).
### Frequency-Domain (AC Analysis)
AC analysis (`.ac`) results display as a Bode plot with two subplots:
- **Top:** Magnitude in dB vs frequency (log scale)
- **Bottom:** Phase in degrees vs frequency (log scale)
The viewer detects AC data automatically when `is_complex` is `true` in the waveform data and switches to this dual-plot layout.
<Aside type="tip">
The -3dB point on a magnitude plot marks the cutoff frequency of a filter. Look for where the magnitude trace drops 3dB below its passband level.
</Aside>
### Oscilloscope Mode
The SpiceBook homepage features an animated oscilloscope display that renders waveform data in a CRT-style aesthetic — cyan traces on a dark background with phosphor-glow effects. This same rendering engine is available for waveform data throughout the application.
The oscilloscope uses a dedicated color palette:
- **Axes:** `#475569` (slate-600)
- **Grid:** `rgba(51, 65, 85, 0.5)` (slate-700 at 50%)
- **Traces:** `#2dd4bf` (cyan-500 for the classic scope look)
## Trace Colors
Multi-signal plots use an 8-color palette designed for readability on both dark and light backgrounds:
| Trace | Color |
|-------|-------|
| 1 | Blue (`#2563eb`) |
| 2 | Red (`#dc2626`) |
| 3 | Green (`#16a34a`) |
| 4 | Amber (`#ca8a04`) |
| 5 | Cyan (`#0891b2`) |
| 6 | Rose (`#e11d48`) |
| 7 | Slate (`#4b5563`) |
| 8 | Orange (`#ea580c`) |
## SVG Generation via API
Waveform plots can also be generated server-side via the REST API. Send the `waveform` data from a simulation response to the SVG endpoint:
```bash
curl -X POST https://spicebook.warehack.ing/api/waveforms/svg/raw \
-H "Content-Type: application/json" \
-d '{
"waveform": { ...waveform data... },
"title": "RC Filter Response",
"width": 800,
"height": 500,
"signals": ["v(out)"]
}' \
-o plot.svg
```
The `signals` array lets you select which traces to include. Omit it to plot all signals. See the [Waveforms & Schematics API reference](/api/waveforms-schematics/) for full details.

View File

@ -0,0 +1,43 @@
/* SpiceBook docs -- blue accent, warm slate grays
* Matches the SpiceBook application theme */
@import "@astrojs/starlight-tailwind";
@import "tailwindcss";
:root {
/* Dark mode (Starlight default: :root = dark)
* Gray scale: gray-1 = highest contrast (lightest), gray-7 = lowest */
--sl-color-accent-low: #172554; /* blue-950 — dark accent bg */
--sl-color-accent: #60a5fa; /* blue-400 — bright on dark bg */
--sl-color-accent-high: #dbeafe; /* blue-100 — light accent text */
--sl-color-gray-1: #f1f5f9; /* slate-100 */
--sl-color-gray-2: #e2e8f0; /* slate-200 */
--sl-color-gray-3: #94a3b8; /* slate-400 */
--sl-color-gray-4: #475569; /* slate-600 */
--sl-color-gray-5: #1e293b; /* slate-800 */
--sl-color-gray-6: #0f172a; /* slate-900 */
--sl-color-gray-7: #020617; /* slate-950 */
}
:root[data-theme="light"] {
/* Light mode: gray-1 = highest contrast (darkest) */
--sl-color-accent-low: #dbeafe; /* blue-100 — light accent bg */
--sl-color-accent: #2563eb; /* blue-600 — darker on light bg */
--sl-color-accent-high: #1e3a5f; /* dark blue — dark accent text */
--sl-color-gray-1: #0f172a; /* slate-900 */
--sl-color-gray-2: #1e293b; /* slate-800 */
--sl-color-gray-3: #475569; /* slate-600 */
--sl-color-gray-4: #94a3b8; /* slate-400 */
--sl-color-gray-5: #cbd5e1; /* slate-300 */
--sl-color-gray-6: #e2e8f0; /* slate-200 */
--sl-color-gray-7: #f8fafc; /* slate-50 */
}
/* Inline code styling */
:not(pre) > code {
background: var(--sl-color-gray-5);
padding: 0.15em 0.35em;
border-radius: 0.25em;
}

5
docs-site/tsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View File

@ -0,0 +1,967 @@
# Reference Architecture: MCP Server + SSE Chat on FastAPI
Pattern for adding an MCP server and a streaming chat assistant to an existing FastAPI application with any frontend framework. First built for the [Margaret Hamilton Digital Archive](https://hamilton.warehack.ing) (Starlight + vanilla JS + FastAPI), then adapted for [SpiceBook](https://spicebook.warehack.ing) (Astro SSR + React 19 + FastAPI). Both are in production.
---
## Origin Story
The Hamilton Archive needed a chat assistant that could answer questions about Apollo-era documents using RAG (retrieval-augmented generation). The requirements were:
1. **MCP server** — so Claude Code and other MCP clients could query the archive programmatically
2. **Chat panel** — floating widget on all pages, streaming LLM responses via SSE, aware of whatever the user was currently reading (a Starlight page, a PDF in the viewer, etc.)
3. **RAG pipeline** — semantic search → batch SQL fetch → character-budget truncation → LLM completion
This was built as vanilla TypeScript (no framework) because the Hamilton Archive uses Starlight with static output — there's no React, no Zustand, no build-time component hydration. The chat widget is a single 1,125-line `.ts` file that does manual DOM manipulation, localStorage conversation management, and inline Lucide SVG icon paths.
When the same pattern was needed for SpiceBook, the architecture was adapted:
- **Frontend**: React 19 with Zustand for state, split across `ChatWidget.tsx` + `chat-store.ts` + `chat-api.ts`
- **Context model**: `PageContext(title, path, description)``NotebookContext(notebook_id, title, engine)` — the domain changed but the shape is identical
- **RAG function**: `_build_context(query)``_build_notebook_context(req)` — this is the main customization point between deployments
- **Caddy routing**: per-route `handle` blocks → single `@api.path` matcher — simpler but less precise
**What stays identical** across both projects:
| Component | Identical? | Notes |
|-----------|:----------:|-------|
| SSE event protocol | Yes | `status`, `token`, `reasoning`, `error`, `done` |
| SSE client parser | Yes | `parseSSEBlock()` with `\n\n` boundary detection |
| `_sse_event()` helper | Yes | Compact JSON formatting |
| httpx streaming client | Yes | Same timeouts, limits, connection pooling |
| `_chat_completion_stream()` | Yes | Same SSE line parser for OpenAI-compatible endpoints |
| MCP mounting pattern | Yes | `mcp.http_app()` + `combine_lifespans()` + `app.mount("/mcp", ...)` |
| FastMCP tool conventions | Yes | Return `str` (JSON), never raise `HTTPException` |
| Conversation limits | Yes | MAX_CONVERSATIONS=20, MAX_MESSAGES=50 |
| Title derivation | Yes | First user message truncated to ~50-60 chars |
---
## Two Frontend Variants
### Variant A: Vanilla TypeScript (Hamilton Archive)
**Single file**: `chat-widget.ts` (1,125 lines) — no framework, no build-time hydration, no npm state library.
**Entry point**:
```typescript
// ChatWidget.astro (11 lines)
import { initChatWidget } from './chat-widget.ts'
initChatWidget()
document.addEventListener('astro:after-swap', initChatWidget)
```
**State management**: Module-scoped variables + direct localStorage:
```typescript
const STORAGE_KEY_INDEX = 'hamilton-chat-conversations'
const STORAGE_KEY_ACTIVE = 'hamilton-chat-active'
const STORAGE_KEY_PREFIX = 'hamilton-chat-conv-'
const STORAGE_KEY_LEGACY = 'hamilton-chat-history' // flat format, auto-migrated
```
Storage uses a split architecture: an index array (conversation metadata) stored separately from individual conversation message arrays (`STORAGE_KEY_PREFIX + id`). This avoids loading all message content when just rendering the history list.
**DOM manipulation**: `data-open`/`data-view` attributes on the widget root element control CSS visibility. Rendering is imperative — `renderMessages()`, `renderHistoryList()`, etc. Lucide icons are pasted as inline SVG path strings (no icon library dependency).
**Key advantage**: Zero JS framework overhead. The widget works in any static site (Starlight, plain HTML, Hugo) because it only needs a `<script>` tag.
**Key disadvantage**: All UI logic (event listeners, DOM updates, scroll management, thinking indicators) lives in one file. A conversation switch requires careful manual capture of `streamingText` before aborting the SSE stream. React's declarative model handles this more cleanly.
### Variant B: React + Zustand (SpiceBook)
**Three files**: `ChatWidget.tsx` (component), `chat-store.ts` (state), `chat-api.ts` (network).
**State management**: Zustand with `persist` middleware:
```typescript
export const useChatStore = create<ChatStore>()(
persist(
(set, get) => ({ /* actions */ }),
{
name: 'spicebook-chat',
partialize: (state) => ({
conversations: state.conversations,
activeConversationId: state.activeConversationId,
}),
},
),
);
```
The `partialize` function excludes transient state (`panelOpen`, `streaming`) from persistence — only conversation data survives page reloads.
**SSE client**: Separate async generator in `chat-api.ts`:
```typescript
export async function* streamChat(opts: ChatStreamOptions): AsyncGenerator<SSEEvent> {
const resp = await fetch(`${API_BASE}/api/chat/stream`, { ... });
const reader = resp.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';
// ... parse SSE blocks by \n\n boundary
}
```
**Context awareness**: Reads from the notebook store (`useNotebookStore`) to pass `NotebookContext` to the API:
```typescript
interface ChatStreamOptions {
question: string;
notebook?: { notebook_id: string; title: string; engine: string } | null;
signal?: AbortSignal;
}
```
**Key advantage**: Declarative state updates. `appendToLastAssistant(chunk)` immutably updates the conversation array — no manual DOM sync needed.
**Key disadvantage**: Requires React hydration. Uses `client:load` in Astro, which means the widget JS downloads and executes on every page load.
---
## Backend Architecture
### 1. MCP Server Module
Both projects use the same layout:
```
backend/src/myapp/
mcp/
__init__.py # FastMCP singleton + import side-effects
tools.py # @mcp.tool() functions wrapping domain logic
chat.py # (Hamilton only) RAG tool + LLM client
```
**Hamilton** registers tools in **two files**: `mcp/tools.py` (search, browse, stats) AND `mcp/chat.py` (the `ask_hamilton` RAG tool + LLM client). The RAG tool lives alongside the streaming client because they share `_build_context()` and `_chat_completion()`.
**SpiceBook** keeps **all tools in `mcp/tools.py`** and the LLM client in a separate `chat/llm.py` module. The chat backend doesn't register any MCP tools — it's purely an HTTP endpoint.
```python
# Hamilton: mcp/__init__.py
import hamilton_search.mcp.chat # Registers ask_hamilton tool
import hamilton_search.mcp.tools # Registers search_archive, get_document, etc.
# SpiceBook: mcp/__init__.py
import spicebook.mcp.tools # Registers list_all_notebooks, simulate_netlist, etc.
```
**Hamilton MCP tools use `Literal` types** for constrained parameters:
```python
ContentType = Literal[
"page", "paper_summary", "source_note", "essay",
"archive", "agc_source", "apollo_context", "agc_highlight",
]
@mcp.tool()
async def search_archive(
query: str,
mode: Literal["hybrid", "semantic", "text"] = "hybrid",
content_type: ContentType | None = None,
limit: int = 10,
) -> str:
```
SpiceBook tools use plain `str` parameters for engine names because there are only two options (`ngspice`, `ltspice`) and validation happens in the engine factory.
### 2. Context Building — The Main Customization Point
The `_build_context()` function is where each deployment diverges. Everything else (SSE framing, LLM streaming, MCP mounting) is reusable.
#### Hamilton: `_build_context(query)` — RAG with Semantic Search
```python
async def _build_context(query: str) -> tuple[str, list[dict]]:
"""Search archive and batch-fetch full document bodies for RAG context."""
async with async_session() as db:
# 1. Hybrid search (semantic + text) for top 5 results
output = await search_documents(q=query, db=db, mode="hybrid", limit=5)
# 2. Batch fetch all documents in one SQL query
slugs = [r.slug for r in output.results]
docs_result = await db.execute(
select(Document).where(Document.slug.in_(slugs))
)
docs_by_slug = {doc.slug: doc for doc in docs_result.scalars()}
# 3. Build context string with character-budget truncation
context_parts = []
chars_used = 0
for slug in slugs: # preserve search result ordering
doc = docs_by_slug.get(slug)
remaining = MAX_CONTEXT_CHARS - chars_used # MAX_CONTEXT_CHARS = 2000
if remaining <= 0:
break
body = doc.body[:remaining] + "..." if len(doc.body) > remaining else doc.body
context_parts.append(f"--- {doc.title} (/{doc.slug}) ---\n{body}")
chars_used += len(body)
return "\n\n".join(context_parts), sources
```
**Pattern**: Search → batch fetch → budget truncation. The two-phase fetch (search for slugs, then `SELECT ... WHERE slug IN (...)`) avoids N+1 queries. Character-budget truncation preserves document ordering from search relevance while staying within LLM context limits.
**Returns**: `(context_text, sources_list)` — the sources list is forwarded to the frontend as an SSE `sources` event so the chat widget can render clickable links.
#### SpiceBook: `_build_notebook_context(req)` — Notebook Content Extraction
```python
async def _build_notebook_context(req: ChatStreamRequest) -> str:
"""Extract SPICE cells and markdown notes from the notebook."""
if not req.notebook or not req.notebook.notebook_id:
return ""
nb = await asyncio.to_thread(load_notebook, settings.notebook_dir, req.notebook.notebook_id)
if nb is None:
return ""
parts = [f'Notebook: "{nb.metadata.title}" (engine: {nb.metadata.engine})']
for i, cell in enumerate(nb.cells):
if cell.type.value == "spice" and cell.source.strip():
parts.append(f"\n--- SPICE Cell {i + 1} ---\n{cell.source.strip()}")
# Include latest simulation result summary
for output in cell.outputs:
if output.output_type in ("simulation_result", "error"):
if not output.data.get("success") and output.data.get("error"):
parts.append(f" [Simulation error: {output.data['error']}]")
elif output.data.get("success") and output.data.get("waveform"):
wf = output.data["waveform"]
var_names = [v.get("name", "") for v in wf.get("variables", [])]
parts.append(f" [Simulation OK: {wf.get('points')} points, signals: {', '.join(var_names)}]")
break
elif cell.type.value == "markdown" and cell.source.strip():
parts.append(f"\n--- Notes ---\n{cell.source.strip()[:500]}")
return "\n".join(parts)
```
**Pattern**: Load → iterate cells → extract domain content. No search step — the user is already viewing a specific notebook, so we load it directly and extract the SPICE netlists plus their latest simulation results. Markdown notes are truncated to 500 chars.
**Returns**: Just a `str` — no sources list because there's nothing to cite. The context is the notebook itself.
> **Async I/O**: Note `asyncio.to_thread(load_notebook, ...)``load_notebook()` uses synchronous `path.read_text()`, which blocks the asyncio event loop. Without `to_thread()`, every chat request briefly freezes *all* other async handlers (health checks, notebook API, other chat streams). This was caught during live debugging when concurrent requests stalled.
### 3. Page Context — What the User Is Looking At
Both projects prepend context about what the user is currently viewing to the question string. The shapes differ but the pattern is identical.
#### Hamilton: `PageContext(title, path, description)`
```python
class PageContext(BaseModel):
title: str = Field("", max_length=200)
path: str = Field("", max_length=500)
description: str = Field("", max_length=500)
@field_validator("path")
@classmethod
def path_must_be_relative(cls, v: str) -> str:
if v and not v.startswith("/"):
raise ValueError("Path must start with /")
return v
```
Frontend detection is viewer-aware — Hamilton has a PDF viewer page that exposes `window.__hamiltonSourceMap` and `window.__pdfMetadata`. The `getPageContext()` function reads the current PDF page number from the viewer DOM, appends PDF metadata (author, creation date), and falls back to standard Starlight `<main h1>` detection:
```typescript
function getPageContext(): { title: string; path: string; description: string } | null {
if (location.pathname === '/viewer' || location.pathname === '/viewer/') {
// Read from source map + viewer state
const titleWithPage = `${docTitle} (page ${currentPage} of ${total})`;
// ... append PDF metadata (Author, Created, Subject, Pages)
return { title: titleWithPage, path, description };
}
// Standard Starlight page
const title = document.querySelector('main h1')?.textContent?.trim();
return { title, path: location.pathname, description: meta.content };
}
```
Backend prepends the context as a bracketed string:
```python
if req.page and req.page.title:
page_context = f'[The user is currently reading: "{req.page.title}" ({req.page.path})'
if req.page.description:
page_context += f"\nDocument description: {req.page.description}"
page_context += "]\n\n"
question = page_context + req.question
```
#### SpiceBook: `NotebookContext(notebook_id, title, engine)`
```python
class NotebookContext(BaseModel):
notebook_id: str = Field("", max_length=200)
title: str = Field("", max_length=200)
engine: str = Field("ngspice", max_length=50)
```
No path validation needed — notebook IDs are used for `load_notebook()`, which already validates against the filesystem. The backend prepends:
```python
if req.notebook and req.notebook.title:
question = f'[User is viewing notebook: "{nb_title}" ({nb_engine})]\n\n' + req.question
```
### 4. System Prompt
Both use `/no_think\n` as the first line to suppress reasoning on models that support it (e.g., Qwen3). This saves tokens since the chat assistant doesn't need to show its reasoning process.
```python
# Hamilton
SYSTEM_PROMPT = (
"/no_think\n"
"You are a knowledgeable research assistant for the Margaret Hamilton Digital Archive. "
"Answer questions using ONLY the provided context from the archive. "
"If the context doesn't contain enough information, say so clearly. "
"Cite specific documents by title when referencing information. "
"Be precise and factual — never fabricate quotes or claims."
)
# SpiceBook
SYSTEM_PROMPT = (
"/no_think\n"
"You are a circuit simulation assistant integrated into SpiceBook, "
"a notebook environment for SPICE circuit design and simulation. "
"Help users understand circuits, debug netlists, interpret simulation "
"results, and design new circuits. ..."
)
```
### 5. SSE Streaming Endpoint
The endpoint structure is identical. Hamilton adds a `sources` event and has a pre-search status flow; SpiceBook skips straight to streaming.
```python
# Hamilton: yields status → sources → tokens → done
async def generate():
yield _sse_event("status", {"text": "Searching the archive…"})
context, sources = await _build_context(question)
yield _sse_event("status", {"text": f"Found {n} relevant documents…"})
yield _sse_event("sources", sources) # ← Hamilton-specific
async for kind, text in _chat_completion_stream(context, question):
yield _sse_event("reasoning" if kind == "reasoning" else "token", {"text": text})
yield _sse_event("done", {})
# SpiceBook: yields status → tokens → done
async def generate():
context = _build_notebook_context(req)
yield _sse_event("status", {"text": "Analyzing circuit context…"})
async for kind, text in chat_completion_stream(context, question):
yield _sse_event("reasoning" if kind == "reasoning" else "token", {"text": text})
yield _sse_event("done", {})
```
### 6. Mount MCP on FastAPI
```python
from contextlib import asynccontextmanager
from fastmcp.utilities.lifespan import combine_lifespans
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
await close_client() # Clean up httpx client
mcp_app = mcp.http_app(path="/", stateless_http=True)
app = FastAPI(
title="MyApp",
lifespan=combine_lifespans(lifespan, mcp_app.lifespan),
)
# Register routers FIRST
app.include_router(chat_router)
app.include_router(other_router)
# Mount MCP LAST (catch-all)
app.mount("/mcp", mcp_app)
```
> **Critical ordering**: `include_router()` before `app.mount("/mcp", ...)`. FastAPI mounts are catch-all — if MCP is mounted first, it swallows routes that share a prefix.
### 7. Domain Error Decoupling
Domain logic must not raise `HTTPException` — it breaks MCP tools which don't run through FastAPI's exception handling. Use domain-specific exceptions:
```python
# Domain layer: raises ValueError
def get_engine(name: str) -> Engine:
if name not in ENGINES:
raise UnsupportedEngineError(f"Unsupported: '{name}'")
# HTTP router: converts at boundary
try:
engine = get_engine(req.engine)
except UnsupportedEngineError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# MCP tool: lets ValueError propagate — FastMCP converts to MCP error
@mcp.tool()
async def simulate(netlist: str, engine: str = "ngspice") -> str:
eng = get_engine(engine) # ValueError propagates naturally
```
---
## Conversation Management Patterns
### Hamilton: Manual localStorage with Legacy Migration
Hamilton stores conversations in a split format: an index array of metadata and individual conversation arrays keyed by ID. It also migrates from an older flat format:
```typescript
function migrateFromLegacy(): void {
const raw = localStorage.getItem(STORAGE_KEY_LEGACY);
if (!raw) return;
// Parse flat message array → create new conversation → save to indexed format
localStorage.removeItem(STORAGE_KEY_LEGACY);
}
```
**Title derivation** uses `deriveTitle()` — takes the first user message, truncates to `TITLE_MAX_LENGTH` (60 chars), and updates the index entry on save. The title stays "New conversation" until the first `saveHistory()` call.
**Two-click delete with auto-revert**:
```typescript
function handleDelete(id: string): void {
if (pendingDeleteId === id) {
// Second click within 3s — confirm deletion
clearPendingDelete();
performDelete(id);
} else {
// First click — show "Delete?" label, start 3s timer
clearPendingDelete();
pendingDeleteId = id;
pendingDeleteTimer = window.setTimeout(() => {
pendingDeleteId = null;
pendingDeleteTimer = null;
if (viewMode === 'history') renderHistoryList(); // revert UI
}, 3000);
renderHistoryList();
}
}
```
This prevents accidental deletions without a modal dialog. The 3-second auto-revert means the user doesn't have to click "cancel" if they change their mind.
### SpiceBook: Zustand `persist` Middleware
SpiceBook's `useChatStore` handles the same patterns declaratively:
```typescript
createConversation: () => {
const id = generateId();
set((s) => ({
conversations: [conv, ...s.conversations].slice(0, MAX_CONVERSATIONS),
activeConversationId: id,
}));
return id;
},
addUserMessage: (text: string) => {
set((s) => ({
conversations: s.conversations.map((c) => {
if (c.id !== convId) return c;
const title = c.messages.length === 0 ? titleFromQuestion(text) : c.title;
return { ...c, messages: [...c.messages, msg].slice(-MAX_MESSAGES), title, updatedAt: now };
}),
}));
},
```
Zustand's `persist` middleware handles serialization and localStorage automatically. The `partialize` function excludes transient state. Deletion is a simple `filter()`.
### SSE Stream State Capture (Hamilton-Specific Edge Case)
Hamilton captures `streamingText` at widget scope so conversation switches can save partial text before aborting:
```typescript
// Module-level state (not per-conversation)
let streamingText = '';
let streamingSources: ChatSource[] = [];
let conversationSwitchInProgress = false;
function startNewConversation(): void {
// Capture partial streaming text BEFORE aborting
if (abortController && streamingText) {
messages.push({
role: 'assistant',
text: streamingText,
sources: streamingSources.length ? streamingSources : undefined,
});
streamingText = '';
streamingSources = [];
}
if (abortController) {
conversationSwitchInProgress = true; // suppress error handler
abortController.abort();
abortController = null;
}
saveHistory();
createNewConversation();
messages = [];
}
```
The `conversationSwitchInProgress` flag is needed because aborting the SSE stream fires the error handler. Without the flag, the error handler would try to save a partial message to the *wrong* conversation (the one we just switched away from).
SpiceBook handles this more simply — React's `useRef` for the AbortController plus Zustand's immutable updates mean the conversation ID is captured in the closure, so there's no risk of writing to the wrong conversation.
---
## Caddy Configuration
### Hamilton: Per-Route `handle` Blocks
Hamilton uses numbered `caddy.handle_N` labels with independent reverse proxy configs per route:
```yaml
labels:
caddy: ${PUBLIC_DOMAIN:-hamilton.l.warehack.ing}
# Search API — standard proxy, no SSE
caddy.handle: /api/search*
caddy.handle.0_reverse_proxy: "{{upstreams 8000}}"
# Health endpoint
caddy.handle_1: /health
caddy.handle_1.0_reverse_proxy: "{{upstreams 8000}}"
# MCP endpoint — standard proxy
caddy.handle_2: /mcp*
caddy.handle_2.0_reverse_proxy: "{{upstreams 8000}}"
# Chat streaming — SSE-optimized proxy
caddy.handle_3: /api/chat*
caddy.handle_3.0_reverse_proxy: "{{upstreams 8000}}"
caddy.handle_3.0_reverse_proxy.flush_interval: "-1"
caddy.handle_3.0_reverse_proxy.transport: "http"
caddy.handle_3.0_reverse_proxy.transport.read_timeout: "0"
caddy.handle_3.0_reverse_proxy.transport.write_timeout: "0"
```
**Advantage**: SSE streaming labels (`flush_interval`, `read_timeout`, `write_timeout`) only apply to `/api/chat*`. Non-streaming routes like `/api/search*` and `/mcp*` use Caddy's default buffering and timeouts, which is more efficient for short-lived requests.
### SpiceBook: Single `@api.path` Matcher
SpiceBook groups all backend routes under one path matcher:
```yaml
labels:
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
# All backend routes go through one matcher with SSE settings
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc /mcp/*"
caddy.reverse_proxy_0: "@api {{upstreams 8000}}"
caddy.reverse_proxy_0.flush_interval: "-1"
caddy.reverse_proxy_0.transport: "http"
caddy.reverse_proxy_0.transport.read_timeout: "0"
caddy.reverse_proxy_0.transport.write_timeout: "0"
caddy.reverse_proxy_0.stream_timeout: "24h"
caddy.reverse_proxy_0.stream_close_delay: "5s"
```
**Advantage**: Simpler — one block instead of four. Adding new API routes just means adding to the path list.
**Disadvantage**: SSE streaming settings apply to *all* backend routes, including `/health` and `/docs`. The `read_timeout: 0` means Caddy will never close idle connections to the health endpoint, which is wasteful (though harmless in practice).
### Comparison Table
| Aspect | Hamilton (`handle` blocks) | SpiceBook (`@api.path` matcher) |
|--------|----------------------------|--------------------------------|
| Label count | ~12 labels across 4 handles | ~8 labels in 1 matcher |
| SSE scope | Only `/api/chat*` | All backend routes |
| Adding routes | New `handle_N` block | Append to path list |
| Timeout precision | Per-route control | Blanket settings |
| Complexity | Higher | Lower |
| Recommended for | Multi-protocol backends (SSE + gRPC + REST) | Simple REST + SSE backends |
### SSE-Required Labels (Both Approaches)
These labels are **mandatory** for SSE streaming through Caddy. Without them, Caddy buffers responses and/or times out long-lived connections:
```yaml
caddy.reverse_proxy.flush_interval: "-1" # Disable response buffering
caddy.reverse_proxy.transport: "http"
caddy.reverse_proxy.transport.read_timeout: "0" # No read timeout
caddy.reverse_proxy.transport.write_timeout: "0" # No write timeout
caddy.reverse_proxy.stream_timeout: "24h" # WebSocket/SSE lifetime
caddy.reverse_proxy.stream_close_delay: "5s" # Graceful close on reload
```
Also set these headers on the `StreamingResponse`:
```python
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # Disable nginx/proxy buffering
},
)
```
---
## SSE Event Protocol
Both projects use this consistent event protocol:
| Event | Payload | Meaning |
|-------|---------|---------|
| `status` | `{"text": "..."}` | Status message (e.g., "Thinking...", "Searching the archive...") |
| `sources` | `[{title, slug, url, score}]` | Hamilton only: search results for citation links |
| `token` | `{"text": "..."}` | Content token from the LLM |
| `reasoning` | `{"text": "..."}` | Reasoning/thinking token (if model supports it) |
| `error` | `{"text": "..."}` | Error message to display to user |
| `done` | `{}` | Stream complete |
**SSE formatting**: Use `json.dumps(data, separators=(",",":"))` (compact, no spaces) to prevent newline fragility in SSE framing. A stray `\n` in the JSON payload would split the SSE block.
---
## Security Hardening
These were identified during Apollo code review of the Hamilton Archive and applied retroactively. Each fix below references the original vulnerability.
### 1. HTTP Client Race Condition
**Hamilton vulnerability**: `_get_chat_client()` was a **sync** function with no lock:
```python
# VULNERABLE (Hamilton original)
def _get_chat_client() -> httpx.AsyncClient:
global _chat_client
if _chat_client is None or _chat_client.is_closed:
_chat_client = httpx.AsyncClient(...) # race condition here
return _chat_client
```
Two concurrent requests could both see `_chat_client is None`, both create a new client, and the first client would be leaked (never closed). Under load this causes connection pool exhaustion.
**Fix**: Use `asyncio.Lock()` with double-checked locking:
```python
# FIXED (SpiceBook, applied back to reference)
_client_lock = asyncio.Lock()
async def _get_chat_client() -> httpx.AsyncClient:
global _chat_client
if _chat_client is not None and not _chat_client.is_closed:
return _chat_client
async with _client_lock:
if _chat_client is not None and not _chat_client.is_closed:
return _chat_client
_chat_client = httpx.AsyncClient(...)
return _chat_client
```
The double-check avoids acquiring the lock on every call — only the first caller (or after client closure) takes the lock.
### 2. Streaming Error Bodies
**Hamilton vulnerability**: Used `resp.raise_for_status()` inside `client.stream()` context:
```python
# VULNERABLE (Hamilton original)
async with client.stream("POST", url, ...) as resp:
resp.raise_for_status() # ← error body is empty in streaming context
```
Inside a streaming context, the response body hasn't been read yet. `raise_for_status()` creates an `HTTPStatusError` with an empty body, making it impossible to diagnose the upstream error.
**Fix**: Read the error body explicitly before raising:
```python
# FIXED (SpiceBook)
async with client.stream("POST", url, ...) as resp:
if resp.status_code >= 400:
body = await resp.aread()
error_text = body[:500].decode("utf-8", errors="replace")
logger.error("LLM gateway returned %d: %s", resp.status_code, error_text)
raise httpx.HTTPStatusError(
f"LLM gateway error {resp.status_code}",
request=resp.request,
response=resp,
)
```
### 3. Bare Exception Handling in Stream
**Hamilton vulnerability**: Used bare `except Exception` in the streaming endpoint:
```python
# VULNERABLE (Hamilton original)
try:
async for kind, text in _chat_completion_stream(context, question):
yield _sse_event(...)
except Exception:
logger.exception("Chat stream completion failed")
yield _sse_event("error", {"text": "Chat completion unavailable"})
```
This catches `asyncio.CancelledError` (a `BaseException` subclass in Python 3.9+, but still caught by careless patterns), `KeyboardInterrupt`, and other exceptions that should propagate. It also swallows the traceback context for debugging.
**Fix**: Catch specific `httpx` exceptions:
```python
# FIXED (SpiceBook)
try:
async for kind, text in chat_completion_stream(context, question):
yield _sse_event(...)
except (
httpx.HTTPStatusError,
httpx.ConnectError,
httpx.ReadTimeout,
httpx.PoolTimeout,
httpx.ConnectTimeout,
) as exc:
logger.warning("Chat stream failed: %s", exc)
yield _sse_event("error", {"text": "Chat service unavailable"})
return
except asyncio.CancelledError:
logger.debug("Chat stream cancelled by client disconnect")
return
```
### 4. Path Traversal Protection
**Hamilton context**: Not directly vulnerable because it uses SQL (slugs are database lookups, not file paths). But filesystem-based apps like SpiceBook that construct paths from user IDs need validation:
```python
import re
_SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,198}[a-z0-9]$")
def validate_item_id(item_id: str) -> str:
if not _SAFE_ID_RE.match(item_id):
raise ValueError(f"Invalid item ID: {item_id!r}")
return item_id
```
### 5. Compact SSE JSON
Use `json.dumps(data, separators=(",",":"))` in `_sse_event()` to prevent newline fragility. Hamilton's original used default separators (`", "`, `": "`) which are fine for most payloads but can introduce visual confusion in debugging.
### Security Checklist
- [ ] **HTTP client init**: `asyncio.Lock()` with double-checked locking for lazy singleton
- [ ] **Streaming errors**: `resp.aread()` before raising on HTTP errors inside `client.stream()`
- [ ] **Specific exceptions**: Catch named `httpx.*` exceptions, not bare `Exception`
- [ ] **CancelledError handling**: Explicit `except asyncio.CancelledError` in SSE generators
- [ ] **Path traversal**: Validate user-provided IDs with regex before constructing file paths
- [ ] **Error decoupling**: Domain logic raises `ValueError`, not `HTTPException`
- [ ] **Compact SSE JSON**: `json.dumps(data, separators=(",",":"))` to prevent newline fragility
- [ ] **SSE response headers**: `Cache-Control: no-cache` + `X-Accel-Buffering: no`
- [ ] **Input validation**: `max_length` on all string fields in Pydantic models
- [ ] **Shutdown cleanup**: `close_client()` in lifespan shutdown to drain connection pool
---
## Frontend Lessons Learned
### 1. Markdown Rendering: Use a Library, Not Regex
LLM responses contain unpredictable markdown: headers, nested lists, tables, blockquotes, fenced code blocks with language tags, horizontal rules. Hand-rolled regex cannot handle all of this.
**Hamilton's approach** — lightweight regex in `renderMarkdown()`:
```typescript
// Hamilton: handles bold, italic, inline code, links, line breaks
function renderMarkdown(text: string): string {
let html = escapeHtml(text);
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/(?<!\*)\*([^*]+?)\*(?!\*)/g, '<em>$1</em>');
html = html.replace(/`([^`]+?)`/g, '<code>$1</code>');
// ... links, newlines
return html;
}
```
This works for the Hamilton Archive because its RAG context produces shorter, less complex responses. It fails badly for SpiceBook where the LLM generates full tutorials with headers, numbered lists, tables, and code blocks.
**SpiceBook's approach** — `marked` + `DOMPurify`:
```typescript
import { marked } from 'marked';
import DOMPurify from 'dompurify';
marked.setOptions({ breaks: true, gfm: true });
function renderMarkdown(text: string): string {
const raw = marked.parse(text, { async: false }) as string;
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target'] });
}
```
**Why both layers**: `marked` handles all GFM syntax (tables, task lists, fenced code). `DOMPurify` strips XSS vectors from the HTML output — critical because the result goes into `dangerouslySetInnerHTML`. The `ADD_ATTR: ['target']` preserves `target="_blank"` on links.
**Cost**: `marked` is ~13 KB gzipped with full GFM support. Worth it for any chat widget where LLM output is unpredictable.
**Recommendation**: Start with `marked` + `DOMPurify` in new projects. Drop to regex only if you control the LLM output format (e.g., RAG with structured templates).
### 2. React 19 Automatic Batching Breaks SSE Streaming
React 19's automatic batching coalesces all `setState` calls within an async function into a single render. When the `for await...of` SSE loop processes many tokens from one `reader.read()` chunk, React defers rendering until the loop yields to the event loop — which means the user sees nothing until the entire stream finishes.
**The symptom**: Chat shows the first partial render (from the initial SSE chunk boundary), then freezes, then shows everything at once when the stream ends.
**Root cause in React 19**:
```typescript
// This produces ONE render at stream end, not N renders per token:
for await (const evt of streamChat({ question })) {
if (evt.event === 'token') {
appendToLastAssistant(evt.data.text); // setState call
// React batches this ↑ — no re-render happens here
}
}
// React renders ONCE here, after the loop exits
```
**Fix — `requestAnimationFrame` token batching**:
Accumulate tokens in a `useRef` (no re-render per token), then flush to Zustand state once per animation frame (~60fps). This gives smooth incremental streaming without overwhelming React:
```typescript
// Refs (no re-render on write)
const pendingTokensRef = useRef('');
const flushRafRef = useRef(0);
// Inside the SSE event loop:
case 'token':
pendingTokensRef.current += evt.data.text;
if (!flushRafRef.current) {
flushRafRef.current = requestAnimationFrame(() => {
if (pendingTokensRef.current) {
appendToLastAssistant(pendingTokensRef.current);
pendingTokensRef.current = '';
}
flushRafRef.current = 0;
});
}
break;
// In the finally block — flush remaining buffered tokens:
finally {
if (flushRafRef.current) {
cancelAnimationFrame(flushRafRef.current);
flushRafRef.current = 0;
}
if (pendingTokensRef.current) {
appendToLastAssistant(pendingTokensRef.current);
pendingTokensRef.current = '';
}
setStreaming(false);
}
```
**Why this works**: `requestAnimationFrame` fires once per display frame (~16ms at 60fps). Multiple tokens arriving within one frame get concatenated in the ref and flushed as a single `appendToLastAssistant` call — which triggers exactly one React render per frame. The browser gets to paint between frames, so the user sees smooth incremental text.
**Why `useRef` instead of `useState`**: Writing to a ref doesn't trigger a render. If we used `useState` for the accumulator, we'd be back to the same batching problem.
**Hamilton doesn't need this** because its vanilla JS `sendQuestion()` function writes directly to `bubble.innerHTML` — no framework batching layer.
### 3. Streaming Verification Checklist
When SSE streaming appears broken, check each layer in order. The issue is usually at exactly one layer:
| Layer | Check | Symptom if broken |
|-------|-------|-------------------|
| **GPU gateway** | `curl -N $GPU_BASE_URL/chat/completions` with `"stream": true` | No tokens arrive at all |
| **Backend httpx** | `print()` inside `aiter_lines()` loop | Tokens arrive at backend but not forwarded |
| **Backend sync I/O** | Check for `path.read_text()`, `open().read()` in async functions | First token delayed by seconds; concurrent requests stall |
| **FastAPI StreamingResponse** | `curl -N http://localhost:8099/api/chat/stream` | Tokens stream from backend but not through proxy |
| **Caddy proxy** | Check `flush_interval: "-1"` label | All tokens arrive at once when stream ends |
| **Frontend fetch/reader** | `console.log()` in `reader.read()` loop | Tokens arrive in browser but UI doesn't update |
| **React rendering** | Check for RAF batching pattern | UI updates once at stream end (React 19 batching) |
**The `asyncio.to_thread()` gotcha**: Any synchronous I/O in an `async def` function blocks the entire event loop. This doesn't just delay the current request — it freezes *all* concurrent async handlers (health checks, other chat streams, notebook API). Wrap sync I/O with `asyncio.to_thread()`:
```python
# BROKEN: blocks event loop during file read
async def _build_notebook_context(req):
nb = load_notebook(settings.notebook_dir, req.notebook.notebook_id) # sync!
# FIXED: runs sync I/O in a thread pool worker
async def _build_notebook_context(req):
nb = await asyncio.to_thread(load_notebook, settings.notebook_dir, req.notebook.notebook_id)
```
---
## Configuration
### Environment Variables
```env
# GPU LLM gateway
GPU_API_KEY=your-api-key
GPU_BASE_URL=https://your-app.gpu.supported.systems/v1
GPU_CHAT_MODEL=qwen3
CHAT_MAX_TOKENS=8192
```
### Docker Compose: `environment` vs `env_file`
`env_file` passes values **literally** from the `.env` file — no variable interpolation. The `environment` section supports `${VAR}` interpolation from the host environment and the `.env` file.
```yaml
services:
frontend:
env_file: .env # Passes GPU_API_KEY=abc123 literally
environment:
# Interpolates SPICEBOOK_DOMAIN from .env, with fallback
- PUBLIC_API_URL=https://${SPICEBOOK_DOMAIN:-localhost:4321}
```
This matters when a build-time variable (like `PUBLIC_API_URL`) needs to be constructed from a runtime variable (like `SPICEBOOK_DOMAIN`). You can't do string interpolation inside `env_file` — you need the `environment` section.
### Dependencies
Add to `pyproject.toml`:
```toml
dependencies = [
"fastmcp>=3.0.0",
"httpx>=0.28.0",
]
```
---
## File Summary Template
| File | Purpose |
|------|---------|
| `backend/src/myapp/mcp/__init__.py` | FastMCP singleton + tool import side-effects |
| `backend/src/myapp/mcp/tools.py` | MCP tool definitions wrapping domain logic |
| `backend/src/myapp/chat/llm.py` | httpx LLM client with connection pooling + streaming |
| `backend/src/myapp/models/chat.py` | Pydantic request models (ViewContext, ChatStreamRequest) |
| `backend/src/myapp/routers/chat.py` | SSE streaming endpoint + context builder |
| `backend/src/myapp/main.py` | Lifespan + MCP mount + router registration |
| `frontend/src/lib/chat-api.ts` | SSE async generator client |
| `frontend/src/lib/chat-store.ts` | Zustand store with localStorage persistence |
| `frontend/src/components/chat/ChatWidget.tsx` | React floating chat panel (SpiceBook) |
| `frontend/src/components/chat-widget.ts` | Vanilla TS chat widget (Hamilton) |
| `docker-compose.yml` | Caddy labels for MCP + SSE routing |
| `.env` | GPU gateway config |
---
## Verification Commands
```bash
# 1. MCP protocol test
curl -X POST http://localhost:8099/mcp \
-H 'Content-Type: application/json'
# 2. Chat SSE test
curl -N -X POST http://localhost:8099/api/chat/stream \
-H 'Content-Type: application/json' \
-d '{"question":"Hello, what can you help with?"}'
# 3. Register MCP server with Claude Code
claude mcp add myapp-local -- \
uv run --directory /path/to/backend myapp
# 4. Test MCP tools via Claude Code
claude -p "List all items" \
--mcp-config .mcp.json \
--allowedTools "mcp__myapp-local__*"
```

View File

@ -9,7 +9,7 @@ export default defineConfig({
adapter: node({ mode: 'standalone' }),
integrations: [
react(),
icon({ include: { lucide: ['plus', 'book-open', 'zap', 'cpu', 'activity', 'circuit-board', 'file-text', 'chevron-down', 'arrow-right', 'check-circle-2'] } }),
icon({ include: { lucide: ['plus', 'book-open', 'zap', 'cpu', 'activity', 'circuit-board', 'file-text', 'chevron-down', 'chevron-right', 'arrow-right', 'check-circle-2', 'clock', 'layers', 'audio-lines', 'play', 'pause'] } }),
],
telemetry: false,
devToolbar: { enabled: false },
@ -24,6 +24,10 @@ export default defineConfig({
optimizeDeps: { exclude: ['@resvg/resvg-js'] },
server: {
host: '0.0.0.0',
// Allow the Caddy reverse proxy domain (Vite 7+ blocks unknown Host headers)
...(process.env.SPICEBOOK_DOMAIN && {
allowedHosts: [process.env.SPICEBOOK_DOMAIN],
}),
...(process.env.VITE_HMR_HOST && {
hmr: {
host: process.env.VITE_HMR_HOST,

18
frontend/components.json Normal file
View File

@ -0,0 +1,18 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/cn",
"ui": "@/components/ui",
"lib": "@/lib"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -23,13 +23,24 @@
"@iconify-json/lucide": "^1.2.90",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.4.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@resvg/resvg-js": "^2.6.2",
"astro": "^5.0.0",
"astro-icon": "^1.1.5",
"astro-seo-meta": "^5.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",
"cmdk": "^1.1.1",
"dompurify": "^3.3.1",
"katex": "^0.16.33",
"lucide-react": "^0.468.0",
"marked": "^17.0.3",
"marked-katex-extension": "^5.1.7",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"satori": "^0.19.2",

Binary file not shown.

View File

@ -1,6 +1,6 @@
# SpiceBook
> Notebook interface for SPICE circuit simulation. Create, edit, and run SPICE netlists in a cell-based notebook UI with waveform visualization and schematic generation. Powered by ngspice.
> Notebook interface for SPICE circuit simulation. Create, edit, and run SPICE netlists in a cell-based notebook UI with waveform visualization and schematic generation. Supports ngspice and LTspice engines.
Base URL: `https://spicebook.warehack.ing`
@ -46,7 +46,7 @@ POST /api/notebooks
}
```
Both fields are optional. Defaults: title = `"Untitled Notebook"`, engine = `"ngspice"`.
Both fields are optional. Defaults: title = `"Untitled Notebook"`, engine = `"ngspice"`. Supported engines: `"ngspice"`, `"ltspice"`.
**Response** `201`
```json
@ -246,6 +246,17 @@ No request body. Cell must be type `spice`.
}
```
**Color-coded resistors**: Resistors with parseable values between 0.01 and 1e9 ohms render as IEEE zigzag symbols with segments colored according to the standard 4-band resistor color code. A 10k resistor shows Brown-Black-Orange-Gold bands directly on the zigzag. The entry/exit half-segments and the gap before the tolerance band use the default wire color, mimicking the physical spacing that indicates reading direction. Parametric values (e.g. `{R_val}`) and out-of-range resistances fall back to a standard monochrome zigzag.
Band-to-segment mapping (7 zigzag sub-segments):
- Seg 0 (entry): wire color
- Seg 1: 1st significant digit
- Seg 2: 2nd significant digit
- Seg 3: multiplier
- Seg 4: gap (wire color, mimics physical spacing)
- Seg 5: tolerance
- Seg 6 (exit): wire color
---
### Waveforms
@ -408,7 +419,7 @@ For AC analysis (`is_complex: true`), `y_magnitude_db` and `y_phase_deg` contain
## SPICE Netlist Primer
SpiceBook uses **ngspice** as its simulation engine. Netlists are plain text describing a circuit and the analysis to perform.
SpiceBook supports **ngspice** and **LTspice** as simulation engines. Netlists are plain text describing a circuit and the analysis to perform.
### Basic structure
@ -544,3 +555,69 @@ GET /health
```json
{"status": "ok", "version": "2026.02.13"}
```
---
## Embedding
Any notebook can be embedded on a third-party page via iframe. No API key or authentication is required.
### Embed URL
```
https://spicebook.warehack.ing/embed/{notebook_id}?theme=dark
```
| Parameter | Values | Default | Description |
|-----------|--------|---------|-------------|
| theme | `dark`, `light` | `dark` | Initial color theme |
### iframe snippet
```html
<iframe
src="https://spicebook.warehack.ing/embed/{notebook_id}?theme=dark"
width="100%" height="600"
style="border: 1px solid #334155; border-radius: 8px;"
allow="clipboard-write"
></iframe>
```
The embed renders the notebook read-only with working simulation — viewers can run SPICE cells and see waveform results.
### Theme control via postMessage
The parent page can switch the embed's theme at runtime:
```javascript
iframe.contentWindow.postMessage(
{ type: 'spicebook-theme', theme: 'light' },
'*'
);
```
Accepted values for `theme`: `"dark"`, `"light"`. Other messages are ignored.
### Auto-resize via postMessage
The embed reports its content height to the parent page whenever the layout changes (initial render, simulation results, expanded cells). The parent listens for `spicebook-resize` messages and updates the iframe height:
```javascript
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'spicebook-resize') {
document.getElementById('spicebook-{notebook_id}').style.height = e.data.height + 'px';
}
});
```
The message payload:
```json
{ "type": "spicebook-resize", "height": 1842 }
```
`height` is `document.documentElement.scrollHeight` in pixels. The embed sends this message on every `ResizeObserver` callback, deduplicated to only fire when height actually changes. The **Embed** button's generated snippet includes this listener automatically.
### Discovering the embed snippet
In the notebook editor UI, the **Embed** button in the toolbar opens a popover with a ready-to-copy iframe snippet (including auto-resize listener) and a theme toggle.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -46,7 +46,9 @@ const engineColor: Record<string, { border: string; bg: string; text: string }>
href={`/notebook/${nb.id}`}
class:list={[
'group block rounded-lg border bg-slate-900/50 overflow-hidden',
'hover:border-blue-500/40 hover:bg-slate-800/70 transition-all',
'hover:border-blue-500/40 hover:bg-slate-800/70',
'hover:-translate-y-1 hover:shadow-lg hover:shadow-black/25',
'transition-all duration-200',
'border-slate-800',
]}
>

View File

@ -25,14 +25,21 @@ interface NotebookCardProps {
}
export function NotebookCard({ notebook }: NotebookCardProps) {
const { id, title, engine, tags, cell_count, modified } = notebook;
const { id, title, engine, tags, cell_count, modified, description, schematic_svg } = notebook;
return (
<a
href={`/notebook/${encodeURIComponent(id)}`}
className="group block rounded-lg border border-slate-800 bg-slate-900/50 overflow-hidden hover:border-blue-500/40 hover:bg-slate-800/70 transition-all"
className="group block rounded-lg border border-slate-800 bg-slate-900/50 overflow-hidden hover:border-blue-500/40 hover:bg-slate-800/70 hover:-translate-y-1 hover:shadow-lg hover:shadow-black/25 transition-all duration-200"
>
<div className="notebook-card-strip" data-engine={engine} />
{schematic_svg ? (
<div
className="w-full h-[120px] bg-[#f8faf6] border-b border-slate-800/60 overflow-hidden flex items-center justify-center [&_svg]:max-w-full [&_svg]:max-h-full [&_svg]:w-auto [&_svg]:h-auto"
dangerouslySetInnerHTML={{ __html: schematic_svg }}
/>
) : (
<div className="notebook-card-strip" data-engine={engine} />
)}
<div className="p-5">
<div className="flex items-start justify-between mb-2">
<h3 className="text-base font-semibold text-slate-100 group-hover:text-blue-400 transition-colors line-clamp-1">
@ -43,6 +50,12 @@ export function NotebookCard({ notebook }: NotebookCardProps) {
</Badge>
</div>
{description && (
<p className="text-sm text-slate-400 leading-relaxed mb-2 line-clamp-2">
{description}
</p>
)}
<div className="flex items-center gap-3 text-xs text-slate-500 mb-3">
<span className="inline-flex items-center gap-1">
<LayoutGrid className="w-3 h-3" />

View File

@ -0,0 +1,605 @@
---
/**
* XY-mode audio oscilloscope display.
* Visual style inspired by the Tektronix 465 (1972).
* Renders stereo audio as Lissajous patterns on a canvas.
*
* Audio: "Spirals" by Jerobeam Fenderson (oscilloscopemusic.com)
* License: CC BY-NC-SA 4.0
* Original visual: Nick Watton (codepen.io/2Mogs)
* Gist: rsp2k (gist.github.com/rsp2k/ac68b1bb290b8124e162987ed1df8d53)
* Modernized: ScriptProcessor → AnalyserNode + rAF
*/
---
<div class="scope-frame">
<!-- Top brand bar — model name doubles as skin picker -->
<div class="scope-brand">
<span class="scope-brand-name">Tektronix</span>
<div class="scope-brand-right">
<button class="scope-brand-model" id="scope-skin-picker" type="button" aria-label="Switch oscilloscope model">SpiceBook</button>
<span class="scope-brand-sub" id="scope-brand-sub"></span>
</div>
</div>
<!-- Recessed CRT bay -->
<div class="scope-crt-bay">
<div class="scope-screen" data-idle="true">
<canvas
class="scope-canvas"
id="scope-canvas"
width="512"
height="512"
role="img"
aria-label="XY oscilloscope displaying Lissajous patterns from stereo audio"
></canvas>
<div class="scope-graticule"></div>
<div class="scope-scanlines"></div>
<!-- Outer Limits easter egg overlay -->
<div class="scope-outer-limits" id="scope-outer-limits" aria-hidden="true">
<p class="scope-ol-text" id="scope-ol-text"></p>
<p class="scope-ol-closing" id="scope-ol-closing" data-visible="false">
We now return control of your television set to
<a href="https://oscilloscopemusic.com" target="_blank" rel="noopener"
class="scope-ol-link" id="scope-ol-link">oscilloscopemusic.com</a>
</p>
</div>
</div>
</div>
<!-- Control panel -->
<div class="scope-panel">
<div class="scope-controls-row">
<!-- SOURCE section -->
<div class="scope-section">
<span class="scope-section-label">Source</span>
<div class="scope-knob scope-source-knob" id="scope-source-knob"
role="button" tabindex="0" aria-label="Select signal source"></div>
<span class="scope-knob-label scope-source-name" id="scope-source-name">Spirals</span>
</div>
<!-- LEVEL / ATTENUATION section -->
<div class="scope-section">
<span class="scope-section-label">Level</span>
<div class="scope-knob scope-volume-knob" id="scope-volume-knob"
role="button" tabindex="0" aria-label="Signal attenuation level"></div>
<span class="scope-knob-label scope-volume-label" id="scope-volume-label">20%</span>
</div>
<!-- VERTICAL section -->
<div class="scope-section">
<span class="scope-section-label">Vertical</span>
<div class="scope-knob scope-easter-knob" role="button" tabindex="0"
aria-label="Vertical control"></div>
<span class="scope-knob-label">Volts/Div</span>
</div>
<!-- HORIZONTAL section -->
<div class="scope-section">
<span class="scope-section-label">Horizontal</span>
<div class="scope-knob scope-easter-knob" role="button" tabindex="0"
aria-label="Horizontal control"></div>
<span class="scope-knob-label">Time/Div</span>
</div>
<!-- TRIGGER / POWER section -->
<div class="scope-section">
<span class="scope-section-label">Trigger</span>
<button
class="scope-toggle"
id="scope-toggle"
type="button"
data-active="false"
aria-label="Start oscilloscope audio"
>&#x23FB; on</button>
<div class="scope-led" id="scope-led" data-on="false" aria-hidden="true"></div>
</div>
</div>
</div>
<!-- Attribution -->
<div class="scope-attribution-bar">
<div class="scope-attribution" id="scope-attribution"></div>
</div>
<!-- Ventilation holes (545A skin only — hidden by default) -->
<div class="scope-vent-holes" aria-hidden="true">
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
<div class="scope-vent-hole"></div>
</div>
</div>
<audio id="scope-audio" src="/spirals_shrt.mp3" loop preload="none"></audio>
<script>
// ── Signal configuration ──────────────────────────────────
const SIGNALS = [
{ id: 'spirals', name: 'Spirals', src: '/spirals_shrt.mp3', artist: 'Jerobeam Fenderson', license: 'CC BY-NC-SA 4.0', link: 'http://oscilloscopemusic.com/' },
{ id: 'function', name: 'Function', src: '/n-spheres/function.mp3', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
{ id: 'intersect', name: 'Intersect', src: '/n-spheres/intersect.mp3', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
{ id: 'attractor', name: 'Attractor', src: '/n-spheres/attractor.mp3', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
{ id: 'flux', name: 'Flux', src: '/n-spheres/flux.mp3', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
{ id: 'core', name: 'Core', src: '/n-spheres/core.mp3', artist: 'Fenderson & Hansi3D', album: 'N-Spheres' },
] as const;
// ── Skin configuration ────────────────────────────────────
const SKINS: Record<string, { brand: string; model: string; sub: string; sections: string[] }> = {
'465': { brand: 'Tektronix', model: 'SpiceBook', sub: '', sections: ['Source', 'Level', 'Vertical', 'Horizontal', 'Trigger'] },
'545a': { brand: 'Tektronix', model: 'Type 545A', sub: 'Oscilloscope', sections: ['Input', 'Atten', 'Vert Ampl', 'Sweep', 'Trigger'] },
};
const SKIN_ORDER = Object.keys(SKINS);
function initSkin() {
const frame = document.querySelector('.scope-frame') as HTMLElement | null;
const picker = document.getElementById('scope-skin-picker') as HTMLButtonElement | null;
const brandName = document.querySelector('.scope-brand-name') as HTMLElement | null;
const brandSub = document.getElementById('scope-brand-sub') as HTMLElement | null;
const sectionLabels = document.querySelectorAll('.scope-section-label');
if (!frame || !picker) return;
// Guard against double-init (DOMContentLoaded + astro:page-load both fire)
if (picker.dataset.skinInit) return;
picker.dataset.skinInit = 'true';
let currentSkin = localStorage.getItem('scope-skin') || '465';
if (!SKINS[currentSkin]) currentSkin = '465';
function applySkin(skinId: string) {
const skin = SKINS[skinId];
if (!skin) return;
// Remove all skin classes, then apply the active one
SKIN_ORDER.forEach(id => frame!.classList.remove(`scope-skin-${id}`));
if (skinId !== '465') {
frame!.classList.add(`scope-skin-${skinId}`);
}
frame!.dataset.skin = skinId;
// Update brand text
if (brandName) brandName.textContent = skin.brand;
picker!.textContent = skin.model;
if (brandSub) {
brandSub.textContent = skin.sub;
brandSub.style.display = skin.sub ? '' : 'none';
}
// Update section labels (Source/Input, Vertical/Vert Ampl, etc.)
sectionLabels.forEach((label, i) => {
if (skin.sections[i]) label.textContent = skin.sections[i];
});
localStorage.setItem('scope-skin', skinId);
currentSkin = skinId;
}
// Apply saved or default skin
applySkin(currentSkin);
// Cycle on click
picker.addEventListener('click', () => {
const idx = SKIN_ORDER.indexOf(currentSkin);
const next = SKIN_ORDER[(idx + 1) % SKIN_ORDER.length];
applySkin(next);
});
}
// ── Signal source selector ────────────────────────────────
function initSignal(audio: HTMLAudioElement, screen: HTMLElement) {
const sourceKnob = document.getElementById('scope-source-knob');
const sourceName = document.getElementById('scope-source-name');
if (!sourceKnob || !sourceName) return;
// Guard against double-init
if (sourceKnob.dataset.signalInit) return;
sourceKnob.dataset.signalInit = 'true';
let signalIndex = parseInt(localStorage.getItem('scope-signal') || '0', 10);
if (signalIndex < 0 || signalIndex >= SIGNALS.length) signalIndex = 0;
function applySignal(idx: number, shouldPlay: boolean) {
const signal = SIGNALS[idx];
signalIndex = idx;
// Update source name label
sourceName!.textContent = signal.name;
// Rotate knob indicator to reflect position (custom prop composes with CSS hover scale)
const rotation = (idx / SIGNALS.length) * 270 - 135;
sourceKnob!.style.setProperty('--knob-rotation', `${rotation}deg`);
// Update attribution
renderAttribution(signal);
// Persist selection
localStorage.setItem('scope-signal', String(idx));
// Update audio source
const wasPlaying = !audio.paused;
if (wasPlaying || shouldPlay) {
screen.dataset.loading = 'true';
audio.pause();
}
audio.src = signal.src;
audio.load();
if (wasPlaying || shouldPlay) {
const onCanPlay = () => {
audio.removeEventListener('canplay', onCanPlay);
screen.dataset.loading = 'false';
audio.play().catch(e => console.error('Signal playback failed:', e));
};
audio.addEventListener('canplay', onCanPlay);
// Timeout fallback — don't leave loading state indefinitely
setTimeout(() => {
screen.dataset.loading = 'false';
}, 15000);
}
}
// Click cycles to next signal
sourceKnob.addEventListener('click', () => {
const next = (signalIndex + 1) % SIGNALS.length;
applySignal(next, false);
});
// Keyboard: Enter/Space to cycle
sourceKnob.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const next = (signalIndex + 1) % SIGNALS.length;
applySignal(next, false);
}
});
// Apply saved signal on init (don't auto-play)
applySignal(signalIndex, false);
return { applySignal, getIndex: () => signalIndex };
}
function renderAttribution(signal: typeof SIGNALS[number]) {
const el = document.getElementById('scope-attribution');
if (!el) return;
const audioSite = '<a href="https://oscilloscopemusic.com" target="_blank" rel="noopener">oscilloscopemusic.com</a>';
const visualCredit = '· Visual: <a href="https://codepen.io/2Mogs" target="_blank" rel="noopener">Nick Watton</a> · <a href="https://gist.github.com/rsp2k/ac68b1bb290b8124e162987ed1df8d53" target="_blank" rel="noopener">rsp2k</a>';
if ('license' in signal && signal.license) {
el.innerHTML = `&ldquo;<a href="${signal.link}" target="_blank" rel="noopener">${signal.name}</a>&rdquo; by ${signal.artist} (<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener license">CC BY-NC-SA</a>) ${visualCredit}`;
} else {
el.innerHTML = `&ldquo;${signal.name}&rdquo; from <em>${('album' in signal) ? signal.album : ''}</em> by ${signal.artist} · ${audioSite} ${visualCredit}`;
}
}
// ── Oscilloscope XY renderer ──────────────────────────────
// Uses AnalyserNode (modern Web Audio) instead of deprecated ScriptProcessor
function initScope() {
const canvas = document.getElementById('scope-canvas') as HTMLCanvasElement | null;
const toggle = document.getElementById('scope-toggle') as HTMLButtonElement | null;
const audio = document.getElementById('scope-audio') as HTMLAudioElement | null;
const screen = canvas?.closest('.scope-screen') as HTMLElement | null;
const led = document.getElementById('scope-led') as HTMLElement | null;
if (!canvas || !toggle || !audio || !screen) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Draw idle state: faint center dot
drawIdle(ctx, canvas.width, canvas.height);
// Init signal source selector
initSignal(audio, screen);
let audioCtx: AudioContext | null = null;
let analyserL: AnalyserNode | null = null;
let analyserR: AnalyserNode | null = null;
let animId: number | null = null;
let isRunning = false;
let source: MediaElementAudioSourceNode | null = null;
// Check reduced motion preference
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
toggle.addEventListener('click', async () => {
if (isRunning) {
stop();
} else {
await start();
}
});
async function start() {
try {
// Create audio context on user gesture (required by browsers)
if (!audioCtx) {
audioCtx = new AudioContext();
// Connect: audio → source → splitter → 2x analyser → destination
source = audioCtx.createMediaElementSource(audio!);
const splitter = audioCtx.createChannelSplitter(2);
analyserL = audioCtx.createAnalyser();
analyserR = audioCtx.createAnalyser();
// 2048 samples gives smooth Lissajous curves
analyserL.fftSize = 2048;
analyserR.fftSize = 2048;
source.connect(splitter);
splitter.connect(analyserL, 0);
splitter.connect(analyserR, 1);
// Also connect to destination so we hear the audio
source.connect(audioCtx.destination);
}
if (audioCtx.state === 'suspended') {
await audioCtx.resume();
}
await audio!.play();
isRunning = true;
screen!.dataset.idle = 'false';
toggle.dataset.active = 'true';
toggle.innerHTML = '&#x23FB; off';
toggle.setAttribute('aria-label', 'Stop oscilloscope audio');
if (led) led.dataset.on = 'true';
if (!prefersReducedMotion) {
renderLoop();
} else {
// Reduced motion: render a single static frame after a brief delay
setTimeout(() => renderFrame(), 200);
}
} catch (e) {
console.error('Oscilloscope audio failed:', e);
}
}
function stop() {
audio!.pause();
isRunning = false;
screen!.dataset.idle = 'true';
toggle.dataset.active = 'false';
toggle.innerHTML = '&#x23FB; on';
toggle.setAttribute('aria-label', 'Start oscilloscope audio');
if (led) led.dataset.on = 'false';
if (animId !== null) {
cancelAnimationFrame(animId);
animId = null;
}
// Fade to idle
drawIdle(ctx!, canvas!.width, canvas!.height);
}
function renderLoop() {
if (!isRunning) return;
renderFrame();
animId = requestAnimationFrame(renderLoop);
}
function renderFrame() {
if (!analyserL || !analyserR || !ctx) return;
const bufLen = analyserL.frequencyBinCount;
const dataL = new Float32Array(bufLen);
const dataR = new Float32Array(bufLen);
analyserL.getFloatTimeDomainData(dataL);
analyserR.getFloatTimeDomainData(dataR);
const w = canvas!.width;
const h = canvas!.height;
const cx = w / 2;
const cy = h / 2;
const scale = w * 0.42;
// Fade previous frame (persistence of phosphor)
ctx.fillStyle = 'rgba(10, 10, 10, 0.25)';
ctx.fillRect(0, 0, w, h);
// Draw dots -- teal phosphor (#2dd4bf)
const dotSize = 1.5;
for (let i = 0; i < bufLen; i++) {
const x = cx + dataR[i] * scale;
const y = cy - dataL[i] * scale;
// Brighter center, dimmer edges (simulate beam intensity)
const dist = Math.sqrt(dataL[i] * dataL[i] + dataR[i] * dataR[i]);
const alpha = Math.max(0.3, 1.0 - dist * 0.4);
ctx.fillStyle = `rgba(45, 212, 191, ${alpha})`;
ctx.fillRect(x - dotSize / 2, y - dotSize / 2, dotSize, dotSize);
}
}
}
function drawIdle(ctx: CanvasRenderingContext2D, w: number, h: number) {
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, w, h);
// Faint center dot
ctx.fillStyle = 'rgba(45, 212, 191, 0.15)';
ctx.beginPath();
ctx.arc(w / 2, h / 2, 3, 0, Math.PI * 2);
ctx.fill();
}
// ── Volume / attenuation control ─────────────────────────
function initVolume() {
const knob = document.getElementById('scope-volume-knob');
const label = document.getElementById('scope-volume-label');
const audio = document.getElementById('scope-audio') as HTMLAudioElement | null;
if (!knob || !label || !audio) return;
if (knob.dataset.volumeInit) return;
knob.dataset.volumeInit = 'true';
const LEVELS = [0.05, 0.10, 0.15, 0.20, 0.25, 0.30, 0.35, 0.40];
let levelIndex = parseInt(localStorage.getItem('scope-volume') || '3', 10);
if (levelIndex < 0 || levelIndex >= LEVELS.length) levelIndex = 3;
function applyLevel(idx: number) {
levelIndex = idx;
const vol = LEVELS[idx];
audio!.volume = vol;
label!.textContent = `${Math.round(vol * 100)}%`;
const rotation = (idx / LEVELS.length) * 270 - 135;
knob!.style.setProperty('--knob-rotation', `${rotation}deg`);
localStorage.setItem('scope-volume', String(idx));
}
knob.addEventListener('click', () => {
applyLevel((levelIndex + 1) % LEVELS.length);
});
knob.addEventListener('keydown', (e: Event) => {
const ke = e as KeyboardEvent;
if (ke.key === 'Enter' || ke.key === ' ') {
ke.preventDefault();
applyLevel((levelIndex + 1) % LEVELS.length);
}
});
applyLevel(levelIndex);
}
// ── Outer Limits easter egg ──────────────────────────────
function initOuterLimits() {
const knobs = document.querySelectorAll('.scope-easter-knob');
const overlay = document.getElementById('scope-outer-limits');
const textEl = document.getElementById('scope-ol-text');
const closingEl = document.getElementById('scope-ol-closing');
const linkEl = document.getElementById('scope-ol-link');
if (!knobs.length || !overlay || !textEl) return;
// Guard against double-init
if (overlay.dataset.olInit) return;
overlay.dataset.olInit = 'true';
const quote = 'There is nothing wrong with your television set. Do not attempt to adjust the picture. We are controlling transmission. We will control the horizontal. We will control the vertical. For the next hour, sit quietly and we will control all that you see and hear.';
let typeTimer: number | null = null;
let utterance: SpeechSynthesisUtterance | null = null;
function show() {
overlay!.dataset.active = 'true';
overlay!.setAttribute('aria-hidden', 'false');
textEl!.textContent = '';
if (closingEl) closingEl.dataset.visible = 'false';
typeText(quote, 0);
speak(quote);
}
function dismiss() {
if (typeTimer) { clearTimeout(typeTimer); typeTimer = null; }
overlay!.dataset.active = 'false';
overlay!.setAttribute('aria-hidden', 'true');
if (utterance && window.speechSynthesis.speaking) {
window.speechSynthesis.cancel();
}
}
function typeText(text: string, i: number) {
if (i >= text.length) {
// Quote done — fade in the closing narration after a dramatic beat
if (closingEl) setTimeout(() => { closingEl.dataset.visible = 'true'; }, 1200);
return;
}
textEl!.textContent += text[i];
typeTimer = window.setTimeout(() => typeText(text, i + 1), 30);
}
function speak(text: string) {
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel();
utterance = new SpeechSynthesisUtterance(text);
utterance.rate = 0.85;
utterance.pitch = 0.7;
const audio = document.getElementById('scope-audio') as HTMLAudioElement | null;
utterance.volume = Math.min(0.8, (audio?.volume ?? 0.2) * 2);
// Prefer a deep English voice if available
const voices = window.speechSynthesis.getVoices();
const preferred = voices.find(v =>
v.lang.startsWith('en') && /male|daniel|david|james|google uk/i.test(v.name)
) || voices.find(v => v.lang.startsWith('en'));
if (preferred) utterance.voice = preferred;
window.speechSynthesis.speak(utterance);
}
// Ensure voices are loaded (some browsers load async)
if ('speechSynthesis' in window) {
window.speechSynthesis.getVoices();
window.speechSynthesis.addEventListener('voiceschanged', () => {});
}
// Knob clicks toggle the overlay
knobs.forEach(knob => {
knob.addEventListener('click', () => {
if (overlay!.dataset.active === 'true') {
dismiss();
} else {
show();
}
});
knob.addEventListener('keydown', (e: Event) => {
const ke = e as KeyboardEvent;
if (ke.key === 'Enter' || ke.key === ' ') {
ke.preventDefault();
if (overlay!.dataset.active === 'true') {
dismiss();
} else {
show();
}
}
});
});
// Let the closing link open without dismissing the overlay
if (linkEl) linkEl.addEventListener('click', (e: Event) => e.stopPropagation());
// Dismiss on overlay click or Escape
overlay.addEventListener('click', dismiss);
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape' && overlay!.dataset.active === 'true') dismiss();
});
}
function initAll() {
initSkin();
initScope();
initVolume();
initOuterLimits();
}
// Init when DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initAll);
} else {
initAll();
}
// Re-init on Astro page transitions
document.addEventListener('astro:page-load', initAll);
</script>
<style is:global>
@import '../styles/oscilloscope.css';
</style>

View File

@ -3,7 +3,7 @@ import { Icon } from 'astro-icon/components';
---
<section class="max-w-6xl mx-auto px-6 py-16">
<div class="grid gap-6 md:grid-cols-[1fr_auto_1fr_auto_1fr] items-start">
<div class="grid gap-6 md:grid-cols-2 md:gap-8 lg:grid-cols-[1fr_auto_1fr_auto_1fr_auto_1fr] items-start">
<!-- Step 1: Write -->
<div class="pipeline-step">
@ -22,9 +22,9 @@ import { Icon } from 'astro-icon/components';
</div>
<!-- Arrow 1→2 -->
<div class="pipeline-arrow">
<Icon name="lucide:arrow-right" class="w-5 h-5 text-slate-600 hidden md:block" />
<Icon name="lucide:chevron-down" class="w-5 h-5 text-slate-600 md:hidden" />
<div class="pipeline-arrow flex md:hidden lg:flex">
<Icon name="lucide:arrow-right" class="w-5 h-5 text-slate-600 hidden lg:block" />
<Icon name="lucide:chevron-down" class="w-5 h-5 text-slate-600 lg:hidden" />
</div>
<!-- Step 2: Simulate -->
@ -49,19 +49,85 @@ import { Icon } from 'astro-icon/components';
</div>
</div>
<p class="pipeline-desc">
Run ngspice simulations with one click
Run simulations with one click — ngspice or LTspice
</p>
</div>
<!-- Arrow 2→3 -->
<div class="pipeline-arrow">
<Icon name="lucide:arrow-right" class="w-5 h-5 text-slate-600 hidden md:block" />
<Icon name="lucide:chevron-down" class="w-5 h-5 text-slate-600 md:hidden" />
<div class="pipeline-arrow flex md:hidden lg:flex">
<Icon name="lucide:arrow-right" class="w-5 h-5 text-slate-600 hidden lg:block" />
<Icon name="lucide:chevron-down" class="w-5 h-5 text-slate-600 lg:hidden" />
</div>
<!-- Step 3: Visualize -->
<!-- Step 3: Schematic -->
<div class="pipeline-step">
<span class="pipeline-number">03</span>
<h3 class="pipeline-title">Schematic</h3>
<div class="pipeline-preview pipeline-schematic">
<svg viewBox="0 0 300 140" xmlns="http://www.w3.org/2000/svg" class="w-full h-full" preserveAspectRatio="xMidYMid meet">
<defs>
<pattern id="sch-minor" width="10" height="10" patternUnits="userSpaceOnUse">
<path d="M 10 0 V 10 H 0" fill="none" stroke="#d4e4d4" stroke-width="0.3"/>
</pattern>
<pattern id="sch-major" width="50" height="50" patternUnits="userSpaceOnUse">
<path d="M 50 0 V 50 H 0" fill="none" stroke="#b0ccb0" stroke-width="0.6"/>
</pattern>
</defs>
<!-- Mims graph paper background -->
<rect width="300" height="140" fill="#f8faf6"/>
<rect width="300" height="140" fill="url(#sch-minor)"/>
<rect width="300" height="140" fill="url(#sch-major)"/>
<!-- Wires -->
<line x1="15" y1="35" x2="50" y2="35" stroke="#333" stroke-width="1.2"/>
<line x1="130" y1="35" x2="170" y2="35" stroke="#333" stroke-width="1.2"/>
<line x1="170" y1="35" x2="245" y2="35" stroke="#333" stroke-width="1.2"/>
<line x1="170" y1="35" x2="170" y2="50" stroke="#333" stroke-width="1.2"/>
<line x1="170" y1="110" x2="170" y2="125" stroke="#333" stroke-width="1.2"/>
<!-- Junction dot -->
<circle cx="170" cy="35" r="2" fill="#333"/>
<!-- Ground symbol -->
<line x1="163" y1="125" x2="177" y2="125" stroke="#333" stroke-width="1.2"/>
<line x1="165" y1="129" x2="175" y2="129" stroke="#333" stroke-width="1"/>
<line x1="167" y1="133" x2="173" y2="133" stroke="#333" stroke-width="0.8"/>
<!-- R1 horizontal zigzag (10k: Brown-Black-Orange-Gold) -->
<polyline points="50,35 60,26 70,44" fill="none" stroke="#8B4513" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="70,44 80,26 90,44" fill="none" stroke="#2d2d2d" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="90,44 100,26 110,44" fill="none" stroke="#FF6B00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="110,44 120,26 130,35" fill="none" stroke="#DAA520" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<!-- R2 vertical zigzag (4.7k: Yellow-Violet-Red-Gold) -->
<polyline points="170,50 161,57.5 179,65" fill="none" stroke="#CCAA00" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="179,65 161,72.5 179,80" fill="none" stroke="#7B2FBE" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="179,80 161,87.5 179,95" fill="none" stroke="#DC2626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="179,95 161,102.5 170,110" fill="none" stroke="#DAA520" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Labels -->
<text x="90" y="18" text-anchor="middle" font-family="ui-monospace, monospace" font-size="8" fill="#555">R1 10k</text>
<text x="195" y="83" font-family="ui-monospace, monospace" font-size="8" fill="#555">R2</text>
<text x="195" y="93" font-family="ui-monospace, monospace" font-size="7" fill="#777">4.7k</text>
<text x="10" y="30" font-family="ui-monospace, monospace" font-size="7" fill="#777">Vin</text>
<text x="248" y="30" font-family="ui-monospace, monospace" font-size="7" fill="#777">Vout</text>
</svg>
</div>
<p class="pipeline-desc">
Auto-generated circuit diagrams with color-coded resistors
</p>
</div>
<!-- Arrow 3→4 -->
<div class="pipeline-arrow flex md:hidden lg:flex">
<Icon name="lucide:arrow-right" class="w-5 h-5 text-slate-600 hidden lg:block" />
<Icon name="lucide:chevron-down" class="w-5 h-5 text-slate-600 lg:hidden" />
</div>
<!-- Step 4: Visualize -->
<div class="pipeline-step">
<span class="pipeline-number">04</span>
<h3 class="pipeline-title">Visualize</h3>
<div class="pipeline-preview pipeline-waveform">
<svg viewBox="0 0 300 140" xmlns="http://www.w3.org/2000/svg" class="w-full h-full" preserveAspectRatio="none">
@ -141,6 +207,11 @@ import { Icon } from 'astro-icon/components';
aspect-ratio: 300 / 140;
}
.pipeline-schematic {
padding: 0;
aspect-ratio: 300 / 140;
}
.pipeline-desc {
font-size: 0.875rem;
color: #94a3b8;
@ -148,16 +219,14 @@ import { Icon } from 'astro-icon/components';
}
.pipeline-arrow {
display: flex;
align-items: center;
justify-content: center;
padding-top: 5rem;
padding-top: 0;
}
@media (max-width: 767px) {
@media (min-width: 1024px) {
.pipeline-arrow {
padding-top: 0;
padding-bottom: 0;
padding-top: 5rem;
}
}
</style>

View File

@ -0,0 +1,92 @@
---
import { Icon } from 'astro-icon/components';
import type { NotebookSummary } from '../lib/types';
interface Props {
notebooks: NotebookSummary[];
}
const FEATURED_IDS = new Set([
'rc-lowpass-filter',
'555-astable-blinker',
'common-emitter-amplifier',
]);
const { notebooks } = Astro.props;
const recent = notebooks
.filter((nb) => !FEATURED_IDS.has(nb.id) && nb.title !== 'Untitled Notebook')
.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime())
.slice(0, 4);
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
} catch {
return iso;
}
}
const engineColor: Record<string, { border: string; bg: string; text: string }> = {
ngspice: { border: 'border-blue-500/30', bg: 'bg-blue-500/10', text: 'text-blue-400' },
ltspice: { border: 'border-amber-500/30', bg: 'bg-amber-500/10', text: 'text-amber-400' },
};
---
{recent.length > 0 && (
<section class="max-w-6xl mx-auto px-6 py-12">
<div class="flex items-center gap-3 mb-6">
<Icon name="lucide:clock" class="w-4 h-4 text-slate-500" />
<h2 class="text-lg font-semibold text-slate-200">Recently Added</h2>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{recent.map((nb) => {
const colors = engineColor[nb.engine] ?? engineColor.ngspice;
return (
<a
href={`/notebook/${nb.id}`}
class:list={[
'group block rounded-lg border bg-slate-900/50 p-4',
'hover:border-blue-500/40 hover:bg-slate-800/70',
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/20',
'transition-all duration-200',
'border-slate-800',
]}
>
<div class="flex items-start justify-between mb-2">
<h3 class="text-sm font-semibold text-slate-100 group-hover:text-blue-400 transition-colors line-clamp-1">
{nb.title}
</h3>
<span class:list={[
'shrink-0 ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border',
colors.border, colors.bg, colors.text,
]}>
{nb.engine}
</span>
</div>
{nb.description && (
<p class="text-xs text-slate-400 leading-relaxed mb-2 line-clamp-2">
{nb.description}
</p>
)}
<div class="flex items-center gap-2 text-[11px] text-slate-500">
<span class="inline-flex items-center gap-1">
<Icon name="lucide:layers" class="w-3 h-3" />
{nb.cell_count} cell{nb.cell_count !== 1 ? 's' : ''}
</span>
<span>&middot;</span>
<span>{formatDate(nb.modified)}</span>
</div>
</a>
);
})}
</div>
</section>
)}

View File

@ -0,0 +1,199 @@
---
/**
* Resistor Color Code Guide — educational reference section.
*
* Left column: annotated SVG showing a 10k resistor zigzag with labeled bands.
* Right column: compact reference table mapping colors to digits and multipliers.
*
* Colors match BAND_HEX from resistor_colors.py so dots match schematic rendering.
*/
// BAND_HEX values from backend/src/spicebook/engine/resistor_colors.py
const bandHex: Record<string, string> = {
black: '#1a1a1a',
brown: '#8B4513',
red: '#CC0000',
orange: '#E67300',
yellow: '#B8960B',
green: '#006400',
blue: '#0000CC',
violet: '#7B00B3',
gray: '#666666',
white: '#D0D0D0',
gold: '#B8860B',
silver: '#808080',
};
const digitBands = [
{ color: 'Black', hex: bandHex.black, digit: 0, multiplier: '×1 \u03A9' },
{ color: 'Brown', hex: bandHex.brown, digit: 1, multiplier: '×10 \u03A9' },
{ color: 'Red', hex: bandHex.red, digit: 2, multiplier: '×100 \u03A9' },
{ color: 'Orange', hex: bandHex.orange, digit: 3, multiplier: '×1 k\u03A9' },
{ color: 'Yellow', hex: bandHex.yellow, digit: 4, multiplier: '×10 k\u03A9' },
{ color: 'Green', hex: bandHex.green, digit: 5, multiplier: '×100 k\u03A9' },
{ color: 'Blue', hex: bandHex.blue, digit: 6, multiplier: '×1 M\u03A9' },
{ color: 'Violet', hex: bandHex.violet, digit: 7, multiplier: '×10 M\u03A9' },
{ color: 'Gray', hex: bandHex.gray, digit: 8, multiplier: '\u2014' },
{ color: 'White', hex: bandHex.white, digit: 9, multiplier: '\u2014' },
];
const toleranceBands = [
{ color: 'Gold', hex: bandHex.gold, value: '\u00B15%' },
{ color: 'Silver', hex: bandHex.silver, value: '\u00B110%' },
];
---
<section class="max-w-6xl mx-auto px-6 py-16">
<p class="text-sm font-semibold tracking-widest text-blue-400 uppercase mb-2">Reference</p>
<h2 class="text-2xl font-bold text-slate-100 mb-3">Reading Resistor Color Codes</h2>
<p class="text-sm text-slate-400 mb-8 max-w-2xl leading-relaxed">
Resistors in SpiceBook schematics use color-coded bands — the same EIA standard
printed on physical components. Each colored segment of the zigzag symbol encodes
a digit, multiplier, or tolerance value.
</p>
<div class="grid gap-8 lg:grid-cols-[1fr_1fr] items-start">
<!-- Left: Annotated resistor diagram -->
<div class="rcg-diagram-card">
<svg viewBox="0 0 460 200" xmlns="http://www.w3.org/2000/svg" class="w-full h-auto" aria-label="Annotated 10k ohm resistor showing 4 color bands">
<!-- Mims graph paper background -->
<defs>
<pattern id="rcg-minor" width="10" height="10" patternUnits="userSpaceOnUse">
<path d="M 10 0 V 10 H 0" fill="none" stroke="#d4e4d4" stroke-width="0.3"/>
</pattern>
<pattern id="rcg-major" width="50" height="50" patternUnits="userSpaceOnUse">
<path d="M 50 0 V 50 H 0" fill="none" stroke="#b0ccb0" stroke-width="0.5"/>
</pattern>
</defs>
<rect width="460" height="200" fill="#f8faf6" rx="4"/>
<rect width="460" height="200" fill="url(#rcg-minor)" rx="4"/>
<rect width="460" height="200" fill="url(#rcg-major)" rx="4"/>
<!-- Lead wires -->
<line x1="40" y1="100" x2="100" y2="100" stroke="#555" stroke-width="2"/>
<line x1="360" y1="100" x2="420" y2="100" stroke="#555" stroke-width="2"/>
<!-- Zigzag body — 7 segments like backend mapping -->
<!-- seg 0: entry half-rise (wire) -->
<polyline points="100,100 120,72" fill="none" stroke="#555" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- seg 1: Band 1 - Brown (1st digit = 1) -->
<polyline points="120,72 160,128" fill="none" stroke={bandHex.brown} stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- seg 2: Band 2 - Black (2nd digit = 0) -->
<polyline points="160,128 200,72" fill="none" stroke={bandHex.black} stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- seg 3: Band 3 - Orange (multiplier = ×1kΩ) -->
<polyline points="200,72 240,128" fill="none" stroke={bandHex.orange} stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- seg 4: gap (wire) -->
<polyline points="240,128 280,72" fill="none" stroke="#555" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- seg 5: Band 4 - Gold (tolerance = ±5%) -->
<polyline points="280,72 320,128" fill="none" stroke={bandHex.gold} stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<!-- seg 6: exit half-rise (wire) -->
<polyline points="320,128 340,100" fill="none" stroke="#555" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Lead wire continuation -->
<line x1="340" y1="100" x2="360" y2="100" stroke="#555" stroke-width="2"/>
<!-- Callout labels with lines -->
<!-- Band 1: Brown -->
<line x1="140" y1="68" x2="140" y2="36" stroke="#888" stroke-width="0.8" stroke-dasharray="2,2"/>
<text x="140" y="30" text-anchor="middle" font-family="ui-monospace, monospace" font-size="10" fill="#555" font-weight="600">1st digit</text>
<text x="140" y="18" text-anchor="middle" font-family="ui-monospace, monospace" font-size="8.5" fill={bandHex.brown}>Brown = 1</text>
<!-- Band 2: Black -->
<line x1="180" y1="132" x2="180" y2="164" stroke="#888" stroke-width="0.8" stroke-dasharray="2,2"/>
<text x="180" y="175" text-anchor="middle" font-family="ui-monospace, monospace" font-size="10" fill="#555" font-weight="600">2nd digit</text>
<text x="180" y="188" text-anchor="middle" font-family="ui-monospace, monospace" font-size="8.5" fill="#444">Black = 0</text>
<!-- Band 3: Orange -->
<line x1="220" y1="68" x2="220" y2="36" stroke="#888" stroke-width="0.8" stroke-dasharray="2,2"/>
<text x="220" y="30" text-anchor="middle" font-family="ui-monospace, monospace" font-size="10" fill="#555" font-weight="600">Multiplier</text>
<text x="220" y="18" text-anchor="middle" font-family="ui-monospace, monospace" font-size="8.5" fill={bandHex.orange}>Orange = ×1k&#937;</text>
<!-- Band 4: Gold -->
<line x1="300" y1="132" x2="300" y2="164" stroke="#888" stroke-width="0.8" stroke-dasharray="2,2"/>
<text x="300" y="175" text-anchor="middle" font-family="ui-monospace, monospace" font-size="10" fill="#555" font-weight="600">Tolerance</text>
<text x="300" y="188" text-anchor="middle" font-family="ui-monospace, monospace" font-size="8.5" fill={bandHex.gold}>Gold = ±5%</text>
<!-- Result value label -->
<rect x="360" y="58" width="90" height="24" rx="3" fill="#f0f2ee" stroke="#b0ccb0" stroke-width="0.8"/>
<text x="405" y="74" text-anchor="middle" font-family="ui-monospace, monospace" font-size="11" fill="#333" font-weight="700">10 k&#937; ±5%</text>
</svg>
</div>
<!-- Right: Color code reference table -->
<div class="rcg-table-card">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-slate-700/60">
<th class="rcg-th text-left py-2.5 pr-2">Color</th>
<th class="rcg-th text-center py-2.5 px-2">Digit</th>
<th class="rcg-th text-left py-2.5 pl-2">Multiplier</th>
</tr>
</thead>
<tbody>
{digitBands.map((band) => (
<tr class="border-b border-slate-800/40">
<td class="py-1.5 pr-2">
<span class="inline-flex items-center gap-2">
<span class="rcg-dot" style={`background: ${band.hex};`}></span>
<span class="text-slate-300 text-xs">{band.color}</span>
</span>
</td>
<td class="text-center py-1.5 px-2 text-slate-300 font-mono text-xs">{band.digit}</td>
<td class="py-1.5 pl-2 text-slate-400 font-mono text-xs">{band.multiplier}</td>
</tr>
))}
</tbody>
</table>
<!-- Tolerance row -->
<div class="mt-4 pt-3 border-t border-slate-700/60">
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Tolerance</p>
<div class="flex gap-6">
{toleranceBands.map((band) => (
<span class="inline-flex items-center gap-2">
<span class="rcg-dot" style={`background: ${band.hex};`}></span>
<span class="text-slate-300 text-xs">{band.color}</span>
<span class="text-slate-400 font-mono text-xs">{band.value}</span>
</span>
))}
</div>
</div>
</div>
</div>
</section>
<style>
.rcg-diagram-card {
border-radius: 0.5rem;
border: 1px solid #1e293b;
overflow: hidden;
background: #f8faf6;
}
.rcg-table-card {
border-radius: 0.5rem;
border: 1px solid #1e293b;
background: rgba(15, 23, 42, 0.5);
padding: 1.25rem;
}
.rcg-th {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #64748b;
}
.rcg-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
border: 1px solid rgba(148, 163, 184, 0.2);
}
</style>

View File

@ -0,0 +1,91 @@
import { useCallback, useEffect, useRef } from 'react';
import { Send, Square, BookOpen } from 'lucide-react';
interface ChatInputProps {
value: string;
onChange: (value: string) => void;
onSend: () => void;
onAbort: () => void;
streaming: boolean;
notebookTitle?: string | null;
}
export default function ChatInput({
value,
onChange,
onSend,
onAbort,
streaming,
notebookTitle,
}: ChatInputProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
}, [value]);
// Focus on mount
useEffect(() => {
setTimeout(() => textareaRef.current?.focus(), 100);
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!streaming && value.trim()) {
onSend();
}
}
},
[onSend, streaming, value],
);
return (
<div className="chat-page-input-container">
{notebookTitle && (
<div className="chat-page-input-context">
<BookOpen size={12} />
<span>{notebookTitle}</span>
</div>
)}
<div className="chat-page-input-row">
<textarea
ref={textareaRef}
className="chat-page-textarea"
placeholder="Ask about circuits, netlists, or simulation…"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
disabled={streaming}
rows={1}
/>
{streaming ? (
<button
className="chat-page-send-btn abort"
onClick={onAbort}
title="Stop generating"
>
<Square size={16} />
</button>
) : (
<button
className="chat-page-send-btn"
onClick={onSend}
disabled={!value.trim()}
title="Send (Enter)"
>
<Send size={16} />
</button>
)}
</div>
<div className="chat-page-input-hint">
<span>Enter to send, Shift+Enter for newline</span>
</div>
</div>
);
}

View File

@ -0,0 +1,84 @@
import { useEffect, useRef } from 'react';
import type { ChatMessage } from '../../lib/chat-store';
import { renderMarkdown } from '../../lib/chat-render';
import 'katex/dist/katex.min.css';
function MessageBubble({ msg, isStreaming }: { msg: ChatMessage; isStreaming?: boolean }) {
return (
<div className={`chat-page-bubble ${msg.role}`}>
{msg.role === 'assistant' ? (
<>
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.text) }} />
{isStreaming && <span className="chat-page-cursor" />}
</>
) : (
<span className="whitespace-pre-wrap">{msg.text}</span>
)}
</div>
);
}
interface ChatMessagesProps {
messages: ChatMessage[];
streaming: boolean;
statusText: string;
reasoningText: string;
reasoningTime: number;
}
export default function ChatMessages({
messages,
streaming,
statusText,
reasoningText,
reasoningTime,
}: ChatMessagesProps) {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const container = bottomRef.current?.parentElement;
if (container) {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
}
}, [messages.length, streaming]);
return (
<div className="chat-page-messages">
{messages.length === 0 && !streaming && (
<div className="chat-page-empty">
<div className="chat-page-empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
</div>
<h2 className="text-lg font-semibold text-[var(--color-sb-text-bright)]">Circuit Assistant</h2>
<p className="text-sm text-[var(--color-sb-muted)] max-w-md text-center">
Ask about circuits, netlists, SPICE simulation, or component behavior.
Select a notebook for context-aware answers.
</p>
</div>
)}
{messages.map((msg, i) => (
<MessageBubble
key={`${msg.timestamp}-${i}`}
msg={msg}
isStreaming={streaming && i === messages.length - 1 && msg.role === 'assistant'}
/>
))}
{reasoningText && streaming && (
<details className="chat-page-reasoning">
<summary>Thinking{reasoningTime > 0 ? ` (${reasoningTime}s)` : '…'}</summary>
<div className="chat-page-reasoning-body">{reasoningText}</div>
</details>
)}
{statusText && (
<div className="chat-page-status">{statusText}</div>
)}
<div ref={bottomRef} />
</div>
);
}

View File

@ -0,0 +1,129 @@
import { useCallback, useState } from 'react';
import { Zap, PanelLeftClose, PanelLeft } from 'lucide-react';
import { useChatStore } from '../../lib/chat-store';
import { useChatStream } from '../../lib/use-chat-stream';
import { Sheet, SheetContent, SheetTitle } from '../ui/sheet';
import { TooltipProvider } from '../ui/tooltip';
import ChatSidebar from './ChatSidebar';
import ChatMessages from './ChatMessages';
import ChatInput from './ChatInput';
import '../../styles/chat-page.css';
export default function ChatPage() {
const getActiveConversation = useChatStore((s) => s.getActiveConversation);
const createConversation = useChatStore((s) => s.createConversation);
const selectedNotebookId = useChatStore((s) => s.selectedNotebookId);
const selectedNotebookMeta = useChatStore((s) => s.selectedNotebookMeta);
// Build notebook override for the streaming hook
const notebookOverride = selectedNotebookId && selectedNotebookMeta
? {
notebook_id: selectedNotebookId,
title: selectedNotebookMeta.title,
engine: selectedNotebookMeta.engine,
}
: null;
const {
sendMessage,
abort,
streaming,
statusText,
reasoningText,
reasoningTime,
} = useChatStream({ notebookOverride });
const [input, setInput] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [desktopSidebarVisible, setDesktopSidebarVisible] = useState(true);
const activeConv = getActiveConversation();
const messages = activeConv?.messages ?? [];
const handleSend = useCallback(() => {
const question = input.trim();
if (!question || streaming) return;
setInput('');
sendMessage(question);
}, [input, streaming, sendMessage]);
const handleNewConversation = useCallback(() => {
if (streaming) abort();
createConversation();
setSidebarOpen(false);
setInput('');
}, [createConversation, streaming, abort]);
return (
<TooltipProvider>
<div className="chat-page-root">
{/* Header */}
<header className="chat-page-header">
<div className="flex items-center gap-3">
{/* Mobile sidebar trigger */}
<button
className="chat-page-menu-btn md:hidden"
onClick={() => setSidebarOpen(true)}
aria-label="Open sidebar"
>
<PanelLeft size={18} />
</button>
{/* Desktop sidebar toggle */}
<button
className="chat-page-menu-btn hidden md:flex"
onClick={() => setDesktopSidebarVisible(!desktopSidebarVisible)}
aria-label={desktopSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
>
{desktopSidebarVisible ? <PanelLeftClose size={18} /> : <PanelLeft size={18} />}
</button>
<a href="/" className="chat-page-logo">
<Zap size={18} className="text-[var(--color-sb-accent)]" />
<span>SpiceBook</span>
</a>
</div>
<div className="chat-page-header-title">
{activeConv?.title ?? 'Circuit Assistant'}
</div>
<div className="w-10" /> {/* Spacer for centering */}
</header>
{/* Main content */}
<div className="chat-page-body">
{/* Desktop sidebar */}
{desktopSidebarVisible && (
<aside className="chat-page-desktop-sidebar">
<ChatSidebar onNewConversation={handleNewConversation} />
</aside>
)}
{/* Mobile sidebar (Sheet drawer) */}
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
<SheetContent side="left" className="p-0 w-[300px]">
<SheetTitle className="sr-only">Conversations</SheetTitle>
<ChatSidebar onNewConversation={handleNewConversation} />
</SheetContent>
</Sheet>
{/* Message area */}
<main className="chat-page-main">
<ChatMessages
messages={messages}
streaming={streaming}
statusText={statusText}
reasoningText={reasoningText}
reasoningTime={reasoningTime}
/>
<ChatInput
value={input}
onChange={setInput}
onSend={handleSend}
onAbort={abort}
streaming={streaming}
notebookTitle={selectedNotebookMeta?.title}
/>
</main>
</div>
</div>
</TooltipProvider>
);
}

View File

@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Plus, MessageSquare, Trash2, Search } from 'lucide-react';
import { useChatStore } from '../../lib/chat-store';
import { ScrollArea } from '../ui/scroll-area';
import { Separator } from '../ui/separator';
import NotebookPicker from './NotebookPicker';
interface ChatSidebarProps {
onNewConversation: () => void;
}
export default function ChatSidebar({ onNewConversation }: ChatSidebarProps) {
const conversations = useChatStore((s) => s.conversations);
const activeId = useChatStore((s) => s.activeConversationId);
const setActive = useChatStore((s) => s.setActiveConversation);
const deleteConv = useChatStore((s) => s.deleteConversation);
const [search, setSearch] = useState('');
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const pendingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const filtered = search.trim()
? conversations.filter((c) =>
c.title.toLowerCase().includes(search.toLowerCase()),
)
: conversations;
const handleDelete = useCallback(
(id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (pendingDeleteId === id) {
if (pendingTimer.current) clearTimeout(pendingTimer.current);
setPendingDeleteId(null);
deleteConv(id);
} else {
if (pendingTimer.current) clearTimeout(pendingTimer.current);
setPendingDeleteId(id);
pendingTimer.current = setTimeout(() => setPendingDeleteId(null), 3000);
}
},
[pendingDeleteId, deleteConv],
);
useEffect(() => {
return () => {
if (pendingTimer.current) clearTimeout(pendingTimer.current);
};
}, []);
return (
<div className="chat-page-sidebar">
{/* New conversation */}
<div className="chat-page-sidebar-header">
<button className="chat-page-new-btn" onClick={onNewConversation}>
<Plus size={14} />
<span>New conversation</span>
</button>
</div>
{/* Search */}
<div className="chat-page-sidebar-search">
<Search size={14} className="text-[var(--color-sb-muted)]" />
<input
type="text"
placeholder="Search conversations…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="chat-page-sidebar-search-input"
/>
</div>
{/* Conversation list */}
<ScrollArea className="flex-1">
<div className="chat-page-conv-list">
{filtered.length === 0 && (
<div className="px-3 py-8 text-center text-sm text-[var(--color-sb-muted)]">
{search ? 'No matching conversations' : 'No conversations yet'}
</div>
)}
{filtered.map((c) => (
<button
key={c.id}
className={`chat-page-conv-item ${c.id === activeId ? 'active' : ''}`}
onClick={() => setActive(c.id)}
>
<MessageSquare size={14} className="shrink-0" />
<div className="flex-1 min-w-0">
<div className="truncate text-sm">{c.title}</div>
<div className="text-xs text-[var(--color-sb-muted)]">
{c.messages.length} message{c.messages.length !== 1 ? 's' : ''}
</div>
</div>
<span
className={`chat-page-conv-delete ${pendingDeleteId === c.id ? 'confirm' : ''}`}
onClick={(e) => handleDelete(c.id, e)}
title={pendingDeleteId === c.id ? 'Click again to delete' : 'Delete'}
>
<Trash2 size={12} />
</span>
</button>
))}
</div>
</ScrollArea>
<Separator />
{/* Notebook picker */}
<NotebookPicker />
</div>
);
}

View File

@ -0,0 +1,306 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Zap,
X,
Plus,
History,
MessageSquare,
Send,
Trash2,
ExternalLink,
} from 'lucide-react';
import { useChatStore } from '../../lib/chat-store';
import type { ChatMessage } from '../../lib/chat-store';
import { renderMarkdown } from '../../lib/chat-render';
import { useChatStream } from '../../lib/use-chat-stream';
import 'katex/dist/katex.min.css';
import '../../styles/chat-widget.css';
// ── Components ──────────────────────────────────────────
function MessageBubble({ msg, isStreaming }: { msg: ChatMessage; isStreaming?: boolean }) {
return (
<div className={`chat-bubble ${msg.role}`}>
{msg.role === 'assistant' ? (
<>
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.text) }} />
{isStreaming && <span className="chat-cursor" />}
</>
) : (
msg.text
)}
</div>
);
}
function HistoryView({
onBack,
}: {
onBack: () => void;
}) {
const conversations = useChatStore((s) => s.conversations);
const activeId = useChatStore((s) => s.activeConversationId);
const setActive = useChatStore((s) => s.setActiveConversation);
const deleteConv = useChatStore((s) => s.deleteConversation);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const pendingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleDelete = useCallback(
(id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (pendingDeleteId === id) {
// Second click — confirm
if (pendingTimer.current) clearTimeout(pendingTimer.current);
setPendingDeleteId(null);
deleteConv(id);
} else {
// First click — arm
if (pendingTimer.current) clearTimeout(pendingTimer.current);
setPendingDeleteId(id);
pendingTimer.current = setTimeout(() => setPendingDeleteId(null), 3000);
}
},
[pendingDeleteId, deleteConv],
);
useEffect(() => {
return () => {
if (pendingTimer.current) clearTimeout(pendingTimer.current);
};
}, []);
if (conversations.length === 0) {
return (
<div className="chat-history-list">
<div className="chat-empty">No conversations yet</div>
</div>
);
}
return (
<div className="chat-history-list">
{conversations.map((c) => (
<button
key={c.id}
className={`chat-history-item ${c.id === activeId ? 'active' : ''}`}
onClick={() => {
setActive(c.id);
onBack();
}}
>
<MessageSquare size={14} />
<span className="chat-history-title">{c.title}</span>
<span className="chat-history-count">{c.messages.length}</span>
<span
className={`chat-history-delete ${pendingDeleteId === c.id ? 'confirm' : ''}`}
onClick={(e) => handleDelete(c.id, e)}
title={pendingDeleteId === c.id ? 'Click again to delete' : 'Delete conversation'}
>
<Trash2 size={12} />
</span>
</button>
))}
</div>
);
}
// ── Main Widget ─────────────────────────────────────────
export default function ChatWidget() {
const panelOpen = useChatStore((s) => s.panelOpen);
const togglePanel = useChatStore((s) => s.togglePanel);
const closePanel = useChatStore((s) => s.closePanel);
const createConversation = useChatStore((s) => s.createConversation);
const getActiveConversation = useChatStore((s) => s.getActiveConversation);
const {
sendMessage,
abort,
streaming,
statusText,
reasoningText,
reasoningTime,
} = useChatStream();
const [input, setInput] = useState('');
const [showHistory, setShowHistory] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const activeConv = getActiveConversation();
const messages = activeConv?.messages ?? [];
// Auto-scroll on new messages — use direct scrollTop instead of scrollIntoView
// because scrollIntoView walks up the DOM and scrolls ALL ancestors (including
// the panel with overflow:hidden), which pushes the header off-screen.
useEffect(() => {
const container = messagesEndRef.current?.parentElement;
if (container) {
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
}
}, [messages.length, streaming]);
// Focus input when panel opens
useEffect(() => {
if (panelOpen && !showHistory) {
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [panelOpen, showHistory]);
// Keyboard: Escape to close
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape' && panelOpen) {
closePanel();
}
}
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [panelOpen, closePanel]);
const handleSend = useCallback(async () => {
const question = input.trim();
if (!question || streaming) return;
setInput('');
await sendMessage(question);
}, [input, streaming, sendMessage]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
const handleNewConversation = useCallback(() => {
if (streaming) {
abort();
}
createConversation();
setShowHistory(false);
setTimeout(() => inputRef.current?.focus(), 100);
}, [createConversation, streaming, abort]);
const handlePopOut = useCallback(() => {
window.open('/chat', '_blank');
}, []);
return (
<>
{/* Floating action button */}
<button
className={`chat-fab ${panelOpen ? 'active' : ''}`}
onClick={togglePanel}
aria-label="Toggle chat"
title="Circuit assistant"
>
{panelOpen ? <X size={20} /> : <Zap size={20} />}
</button>
{/* Chat panel */}
{panelOpen && (
<div className="chat-panel">
{/* Header */}
<div className="chat-header">
<Zap size={16} className="text-blue-500" />
<span className="chat-header-title">
{showHistory
? 'Conversations'
: activeConv?.title ?? 'Circuit Assistant'}
</span>
<button
className="chat-header-btn"
onClick={handlePopOut}
title="Open full chat page"
>
<ExternalLink size={16} />
</button>
<button
className="chat-header-btn"
onClick={handleNewConversation}
title="New conversation"
>
<Plus size={16} />
</button>
<button
className="chat-header-btn"
onClick={() => setShowHistory(!showHistory)}
title={showHistory ? 'Back to chat' : 'Conversation history'}
>
{showHistory ? <MessageSquare size={16} /> : <History size={16} />}
</button>
<button
className="chat-header-btn"
onClick={closePanel}
title="Close"
>
<X size={16} />
</button>
</div>
{/* Body */}
{showHistory ? (
<HistoryView onBack={() => setShowHistory(false)} />
) : (
<>
<div className="chat-messages">
{messages.length === 0 && !streaming && (
<div className="chat-empty">
Ask about circuits, netlists, or simulation results
</div>
)}
{messages.map((msg, i) => (
<MessageBubble
key={`${msg.timestamp}-${i}`}
msg={msg}
isStreaming={
streaming &&
i === messages.length - 1 &&
msg.role === 'assistant'
}
/>
))}
{reasoningText && streaming && (
<details className="chat-reasoning">
<summary>Thinking{reasoningTime > 0 ? ` (${reasoningTime}s)` : '…'}</summary>
<div className="chat-reasoning-body">{reasoningText}</div>
</details>
)}
{statusText && (
<div className="chat-status">{statusText}</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="chat-input-bar">
<input
ref={inputRef}
className="chat-input"
type="text"
placeholder="Ask a circuit question…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={streaming}
/>
<button
className="chat-send-btn"
onClick={handleSend}
disabled={streaming || !input.trim()}
title="Send"
>
<Send size={16} />
</button>
</div>
</>
)}
</div>
)}
</>
);
}

View File

@ -0,0 +1,97 @@
import { useCallback, useEffect, useState } from 'react';
import { BookOpen, X } from 'lucide-react';
import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover';
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../ui/command';
import { listNotebooks } from '../../lib/api';
import type { NotebookSummary } from '../../lib/types';
import { useChatStore } from '../../lib/chat-store';
export default function NotebookPicker() {
const [open, setOpen] = useState(false);
const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
const [loading, setLoading] = useState(false);
const selectedId = useChatStore((s) => s.selectedNotebookId);
const selectedMeta = useChatStore((s) => s.selectedNotebookMeta);
const setSelected = useChatStore((s) => s.setSelectedNotebook);
const clearSelected = useChatStore((s) => s.clearSelectedNotebook);
useEffect(() => {
if (!open) return;
setLoading(true);
listNotebooks()
.then(setNotebooks)
.catch(() => setNotebooks([]))
.finally(() => setLoading(false));
}, [open]);
const handleSelect = useCallback(
(nb: NotebookSummary) => {
setSelected(nb.id, { title: nb.title, engine: nb.engine });
setOpen(false);
},
[setSelected],
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
clearSelected();
},
[clearSelected],
);
return (
<div className="chat-page-notebook-picker">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="chat-page-notebook-btn">
<BookOpen size={14} />
<span className="flex-1 text-left truncate">
{selectedMeta ? selectedMeta.title : 'Select notebook…'}
</span>
{selectedId && (
<span
className="chat-page-notebook-clear"
onClick={handleClear}
title="Clear selection"
>
<X size={12} />
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start" side="top">
<Command>
<CommandInput placeholder="Search notebooks…" />
<CommandList>
<CommandEmpty>
{loading ? 'Loading…' : 'No notebooks found'}
</CommandEmpty>
<CommandGroup>
{notebooks.map((nb) => (
<CommandItem
key={nb.id}
value={`${nb.title} ${nb.id}`}
onSelect={() => handleSelect(nb)}
>
<BookOpen size={14} className="shrink-0 text-[var(--color-sb-muted)]" />
<div className="flex flex-col gap-0.5 overflow-hidden">
<span className="truncate text-sm">{nb.title}</span>
<span className="text-xs text-[var(--color-sb-muted)]">
{nb.engine} · {nb.cell_count} cell{nb.cell_count !== 1 ? 's' : ''}
</span>
</div>
{nb.id === selectedId && (
<span className="ml-auto text-[var(--color-sb-accent)] text-xs"></span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@ -4,12 +4,6 @@ import * as api from '../../lib/api';
import { EmbedCell } from './EmbedCell';
import { Loader2 } from 'lucide-react';
// Origins allowed to send postMessage theme changes to the embed.
// Must stay in sync with CSP_FRAME_ANCESTORS / CORS_EXTRA_ORIGINS.
const ALLOWED_MESSAGE_ORIGINS = new Set([
'https://forrest.warehack.ing',
]);
interface EmbedViewerProps {
notebookId: string;
initialTheme: string;
@ -33,8 +27,6 @@ export default function EmbedViewer({ notebookId, initialTheme }: EmbedViewerPro
// Listen for postMessage theme changes from parent iframe host
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (!ALLOWED_MESSAGE_ORIGINS.has(event.origin)) return;
if (event.data?.type === 'spicebook-theme') {
const incoming = event.data.theme;
if (incoming === 'dark' || incoming === 'light') {
@ -46,6 +38,26 @@ export default function EmbedViewer({ notebookId, initialTheme }: EmbedViewerPro
return () => window.removeEventListener('message', handleMessage);
}, []);
// Report content height to parent frame for auto-resize
useEffect(() => {
if (typeof window === 'undefined' || window.parent === window) return;
let lastHeight = 0;
function reportHeight() {
const height = document.documentElement.scrollHeight;
if (height !== lastHeight) {
lastHeight = height;
window.parent.postMessage({ type: 'spicebook-resize', height }, '*');
}
}
const observer = new ResizeObserver(reportHeight);
observer.observe(document.body);
reportHeight();
return () => observer.disconnect();
}, []);
// Keep a ref to notebook for use inside handleRun to avoid stale closures
const notebookRef = useRef(notebook);
notebookRef.current = notebook;

View File

@ -30,7 +30,7 @@ function AddCellButton({ afterId }: { afterId?: string }) {
];
return (
<div className="flex justify-center py-1 opacity-0 hover:opacity-100 focus-within:opacity-100 transition-opacity">
<div className="flex justify-center py-2 opacity-0 -translate-y-1 hover:opacity-100 hover:translate-y-0 focus-within:opacity-100 focus-within:translate-y-0 transition-all duration-200 ease-out">
<Dropdown
trigger={
<Button variant="ghost" size="sm" className="text-slate-600 hover:text-slate-400">
@ -61,7 +61,7 @@ export function CellList() {
}
return (
<div className="space-y-0">
<div className="space-y-3">
{cells.map((cell, index) => {
const isFirst = index === 0;
const isLast = index === cells.length - 1;

View File

@ -90,7 +90,7 @@ export default function NotebookEditor({ notebookId }: NotebookEditorProps) {
</main>
{/* Status bar at bottom */}
<footer className="border-t border-slate-800 bg-slate-950 px-4 py-1.5 text-[11px] text-slate-600 flex items-center gap-4">
<footer className="border-t border-slate-800/60 bg-slate-950 px-4 py-2.5 text-[11px] text-slate-500 flex items-center gap-4">
<span>
{notebook.cells.length} cell
{notebook.cells.length !== 1 ? 's' : ''}

View File

@ -1,6 +1,8 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { ZoomIn, ZoomOut, Maximize2, Download } from 'lucide-react';
import { ZoomIn, ZoomOut, Maximize2, Minimize2, Download } from 'lucide-react';
import DOMPurify from 'dompurify';
import { cn } from '../../../lib/cn';
import { useFullscreen } from '../../../lib/useFullscreen';
interface EditState {
component: string;
@ -29,6 +31,7 @@ function sanitizeSvg(raw: string): string {
export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicViewerProps) {
const [zoomIndex, setZoomIndex] = useState(2); // Start at 100%
const [editState, setEditState] = useState<EditState | null>(null);
const { isFullscreen, toggleFullscreen } = useFullscreen();
const containerRef = useRef<HTMLDivElement>(null);
const svgWrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@ -189,44 +192,52 @@ export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicV
);
return (
<div className="relative">
<div className={cn('relative', isFullscreen && 'fixed inset-0 z-50 bg-sb-bg flex flex-col')}>
{/* Toolbar */}
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-slate-700/50 bg-slate-800/50">
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-sb-border/50 bg-sb-cell/50">
<button
onClick={zoomOut}
disabled={zoomIndex === 0}
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
className="p-1 rounded text-sb-muted hover:text-sb-text hover:bg-sb-border/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Zoom out"
>
<ZoomOut className="w-3.5 h-3.5" />
</button>
<span className="text-xs text-slate-500 tabular-nums w-10 text-center">
<button
onClick={fitToWidth}
className="text-xs text-sb-muted hover:text-sb-text tabular-nums w-10 text-center transition-colors"
title="Reset to 100%"
>
{Math.round(zoom * 100)}%
</span>
</button>
<button
onClick={zoomIn}
disabled={zoomIndex === ZOOM_STEPS.length - 1}
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
className="p-1 rounded text-sb-muted hover:text-sb-text hover:bg-sb-border/50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Zoom in"
>
<ZoomIn className="w-3.5 h-3.5" />
</button>
<button
onClick={fitToWidth}
className="p-1 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
title="Reset zoom"
onClick={toggleFullscreen}
className="p-1 rounded text-sb-muted hover:text-sb-text hover:bg-sb-border/50 transition-colors"
title={isFullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen'}
>
<Maximize2 className="w-3.5 h-3.5" />
{isFullscreen ? (
<Minimize2 className="w-3.5 h-3.5" />
) : (
<Maximize2 className="w-3.5 h-3.5" />
)}
</button>
<div className="flex-1" />
{componentMap && Object.keys(componentMap).length > 0 && (
<span className="text-[10px] text-slate-600 mr-2">
<span className="text-[10px] text-sb-muted mr-2">
click values to edit
</span>
)}
<button
onClick={downloadSvg}
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition-colors"
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-sb-muted hover:text-sb-text hover:bg-sb-border/50 transition-colors"
title="Download SVG"
>
<Download className="w-3.5 h-3.5" />
@ -237,8 +248,11 @@ export function SchematicViewer({ svg, componentMap, onValueChange }: SchematicV
{/* SVG Canvas */}
<div
ref={containerRef}
className="overflow-auto bg-white rounded-b-lg relative"
style={{ maxHeight: '500px' }}
className={cn(
'overflow-auto bg-white relative',
isFullscreen ? 'flex-1' : 'rounded-b-lg',
)}
style={isFullscreen ? undefined : { maxHeight: '500px' }}
>
<div
ref={svgWrapperRef}

View File

@ -16,7 +16,7 @@ function highlightLine(line: string): { text: string; className: string } {
if (lower.includes('warning') || lower.includes('warn')) {
return { text: line, className: 'text-amber-400' };
}
return { text: line, className: 'text-slate-400' };
return { text: line, className: 'text-sb-muted' };
}
export function SimulationLog({
@ -33,9 +33,9 @@ export function SimulationLog({
const lines = log.split('\n').filter((l) => l.trim().length > 0);
return (
<div className="border-t border-slate-700/50">
<div className="border-t border-sb-border/50">
<button
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-slate-500 hover:text-slate-400 transition-colors"
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-sb-muted hover:text-sb-text transition-colors"
onClick={() => setOpen(!open)}
>
{open ? (

View File

@ -1,6 +1,9 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import { Maximize2, Minimize2 } from 'lucide-react';
import type { WaveformData } from '../../../lib/types';
import { formatAxisValue, TRACE_COLORS } from '../../../lib/waveform-utils';
import { cn } from '../../../lib/cn';
import { useFullscreen } from '../../../lib/useFullscreen';
import { ScopeWaveformViewer } from './ScopeWaveformViewer';
// uPlot is a vanilla JS lib; import it and its CSS
@ -79,9 +82,11 @@ function buildOpts(
function TransientPlot({
waveform,
width,
height,
}: {
waveform: WaveformData;
width: number;
height?: number;
}) {
const plotRef = useRef<HTMLDivElement>(null);
const uplotRef = useRef<uPlot | null>(null);
@ -102,7 +107,7 @@ function TransientPlot({
// Determine y-axis type from variable types
const yType =
waveform.variables.length > 1 ? waveform.variables[1].type : 'voltage';
const opts = buildOpts(waveform, width, 280, waveform.x_type, yType);
const opts = buildOpts(waveform, width, height ?? 280, waveform.x_type, yType);
const u = new uPlot(opts, data, plotRef.current);
uplotRef.current = u;
@ -111,7 +116,7 @@ function TransientPlot({
u.destroy();
uplotRef.current = null;
};
}, [waveform, width]);
}, [waveform, width, height]);
return <div ref={plotRef} />;
}
@ -119,9 +124,11 @@ function TransientPlot({
function ACPlot({
waveform,
width,
height,
}: {
waveform: WaveformData;
width: number;
height?: number;
}) {
const magRef = useRef<HTMLDivElement>(null);
const phaseRef = useRef<HTMLDivElement>(null);
@ -147,7 +154,8 @@ function ACPlot({
),
];
const magOpts = buildOpts(waveform, width, 220, 'frequency', 'dB');
const magH = height ? Math.round(height * 0.55) : 220;
const magOpts = buildOpts(waveform, width, magH, 'frequency', 'dB');
magOpts.series = [
{ label: 'Frequency' },
...magTraces.map((name, i) => ({
@ -171,10 +179,11 @@ function ACPlot({
),
];
const phaseH = height ? height - Math.round(height * 0.55) : 180;
const phaseOpts = buildOpts(
waveform,
width,
180,
phaseH,
'frequency',
'degrees',
);
@ -198,7 +207,7 @@ function ACPlot({
magPlotRef.current = null;
phasePlotRef.current = null;
};
}, [waveform, width]);
}, [waveform, width, height]);
return (
<div className="space-y-2">
@ -236,25 +245,28 @@ function ScopeToggleIcon({ active }: { active: boolean }) {
export function WaveformViewer({ waveform, className }: WaveformViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const { isFullscreen, toggleFullscreen } = useFullscreen();
const [scopeMode, setScopeMode] = useState(() => {
try { return localStorage.getItem('spicebook-scope-mode') === 'true'; }
catch { return false; }
});
const updateWidth = useCallback(() => {
const updateDimensions = useCallback(() => {
if (containerRef.current) {
setWidth(containerRef.current.clientWidth);
setHeight(containerRef.current.clientHeight);
}
}, []);
useEffect(() => {
updateWidth();
const observer = new ResizeObserver(updateWidth);
updateDimensions();
const observer = new ResizeObserver(updateDimensions);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, [updateWidth]);
}, [updateDimensions]);
const toggleScope = useCallback(() => {
setScopeMode(prev => {
@ -268,36 +280,71 @@ export function WaveformViewer({ waveform, className }: WaveformViewerProps) {
const isEmpty =
traceCount === 0 && !waveform.y_magnitude_db && !waveform.y_phase_deg;
// In fullscreen, subtract toolbar + padding from available height
const plotWidth = isFullscreen ? Math.max(width - 32, 100) : width;
const plotHeight = isFullscreen ? Math.max(height - 80, 200) : undefined;
return (
<div ref={containerRef} className={`relative ${className || ''}`}>
{/* Scope toggle button */}
<div
ref={containerRef}
className={cn(
'relative',
className,
isFullscreen && 'fixed inset-0 z-50 bg-sb-surface flex flex-col',
)}
>
{/* Control buttons */}
{!isEmpty && (
<button
type="button"
onClick={toggleScope}
className="absolute top-1 right-1 z-10 p-1 rounded hover:bg-slate-700/50 transition-colors"
aria-label={scopeMode ? 'Exit oscilloscope view' : 'Switch to oscilloscope view'}
title={scopeMode ? 'Standard view' : 'Oscilloscope view'}
<div
className={cn(
'flex items-center gap-1 z-10',
isFullscreen
? 'px-4 py-2 justify-end border-b border-sb-border/50'
: 'absolute top-1 right-1',
)}
>
<ScopeToggleIcon active={scopeMode} />
</button>
<button
type="button"
onClick={toggleFullscreen}
className="p-1 rounded text-sb-muted hover:text-sb-text hover:bg-sb-border/50 transition-colors"
title={isFullscreen ? 'Exit fullscreen (Esc)' : 'Fullscreen'}
>
{isFullscreen ? (
<Minimize2 className="w-[18px] h-[18px]" />
) : (
<Maximize2 className="w-[18px] h-[18px]" />
)}
</button>
<button
type="button"
onClick={toggleScope}
className="p-1 rounded hover:bg-sb-border/50 transition-colors"
aria-label={scopeMode ? 'Exit oscilloscope view' : 'Switch to oscilloscope view'}
title={scopeMode ? 'Standard view' : 'Oscilloscope view'}
>
<ScopeToggleIcon active={scopeMode} />
</button>
</div>
)}
{isEmpty ? (
<div className="text-slate-500 text-sm py-4 text-center">
No waveform data to display.
</div>
) : scopeMode ? (
<ScopeWaveformViewer
waveform={waveform}
width={width}
onExitScope={toggleScope}
/>
) : waveform.is_complex ? (
<ACPlot waveform={waveform} width={width} />
) : (
<TransientPlot waveform={waveform} width={width} />
)}
{/* Plot area */}
<div className={cn(isFullscreen && 'flex-1 min-h-0 px-4 pb-4')}>
{isEmpty ? (
<div className="text-sb-muted text-sm py-4 text-center">
No waveform data to display.
</div>
) : scopeMode ? (
<ScopeWaveformViewer
waveform={waveform}
width={plotWidth}
onExitScope={toggleScope}
/>
) : waveform.is_complex ? (
<ACPlot waveform={waveform} width={plotWidth} height={plotHeight} />
) : (
<TransientPlot waveform={waveform} width={plotWidth} height={plotHeight} />
)}
</div>
</div>
);
}

View File

@ -0,0 +1,108 @@
import { useState, useRef } from 'react';
import { Code, Check, Copy } from 'lucide-react';
import { Popover, PopoverTrigger, PopoverContent } from '../../ui/popover';
import { Button } from '../../ui/Button';
import { useNotebookStore } from '../../../lib/notebook-store';
export function EmbedDialog() {
const { notebookId } = useNotebookStore();
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
if (!notebookId) return null;
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const iframeId = `spicebook-${notebookId}`;
const snippet = `<iframe
id="${iframeId}"
src="${origin}/embed/${notebookId}?theme=${theme}"
width="100%" height="600"
style="border: 1px solid #334155; border-radius: 8px;"
allow="clipboard-write"
></iframe>
<script>
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'spicebook-resize') {
var f = document.getElementById('${iframeId}');
if (f) f.style.height = e.data.height + 'px';
}
});
</script>`;
function handleCopy() {
navigator.clipboard.writeText(snippet).then(() => {
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 2000);
});
}
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" title="Embed this notebook">
<Code className="w-3.5 h-3.5" />
Embed
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<div className="space-y-3">
<p className="text-xs font-medium text-slate-300">
Embed this notebook on any page
</p>
{/* Theme toggle */}
<div className="flex gap-1 rounded-md bg-slate-800 p-0.5">
<button
onClick={() => setTheme('dark')}
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
theme === 'dark'
? 'bg-slate-600 text-slate-100'
: 'text-slate-400 hover:text-slate-300'
}`}
>
Dark
</button>
<button
onClick={() => setTheme('light')}
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
theme === 'light'
? 'bg-slate-600 text-slate-100'
: 'text-slate-400 hover:text-slate-300'
}`}
>
Light
</button>
</div>
{/* Snippet */}
<textarea
readOnly
value={snippet}
rows={12}
className="w-full resize-none rounded-md border border-slate-600 bg-slate-800 p-2 font-mono text-[11px] text-slate-300 focus:outline-none focus:border-blue-500"
/>
{/* Copy button */}
<Button
variant="primary"
size="sm"
onClick={handleCopy}
className="w-full"
>
{copied ? (
<Check className="w-3.5 h-3.5" />
) : (
<Copy className="w-3.5 h-3.5" />
)}
{copied ? 'Copied!' : 'Copy snippet'}
</Button>
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -12,6 +12,7 @@ import {
import { Button } from '../../ui/Button';
import { Badge } from '../../ui/Badge';
import { Dropdown } from '../../ui/Dropdown';
import { EmbedDialog } from './EmbedDialog';
import { useNotebookStore } from '../../../lib/notebook-store';
import type { CellType } from '../../../lib/types';
@ -124,9 +125,7 @@ export function NotebookToolbar() {
className="bg-slate-800 border border-slate-600 rounded text-xs px-2 py-1 text-slate-300 focus:outline-none focus:border-blue-500 cursor-pointer"
>
<option value="ngspice">ngspice</option>
<option value="ltspice" disabled>
ltspice (soon)
</option>
<option value="ltspice">ltspice</option>
</select>
{/* Dirty indicator */}
@ -161,6 +160,9 @@ export function NotebookToolbar() {
Run All
</Button>
{/* Embed snippet */}
<EmbedDialog />
{/* Save */}
<Button
variant={dirty ? 'primary' : 'ghost'}

View File

@ -0,0 +1,111 @@
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '../../lib/cn';
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-lg bg-[var(--color-sb-surface)] text-[var(--color-sb-text)]',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b border-[var(--color-sb-border)] px-3" cmdk-input-wrapper="">
<Search size={14} className="mr-2 shrink-0 text-[var(--color-sb-muted)]" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-9 w-full rounded-md bg-transparent py-2 text-sm outline-none placeholder:text-[var(--color-sb-muted)] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm text-[var(--color-sb-muted)]" {...props} />
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-[var(--color-sb-text)] [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-[var(--color-sb-muted)]',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none',
'data-[selected=true]:bg-[var(--color-sb-cell)] data-[selected=true]:text-[var(--color-sb-text-bright)]',
'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandSeparator = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-[var(--color-sb-border)]', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
export {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
};

View File

@ -0,0 +1,32 @@
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '../../lib/cn';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-lg border border-[var(--color-sb-border)] bg-[var(--color-sb-surface)] p-4 shadow-md outline-none',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };

View File

@ -0,0 +1,43 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '../../lib/cn';
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[var(--color-sb-border)] hover:bg-[var(--color-sb-muted)]" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@ -0,0 +1,23 @@
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '../../lib/cn';
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-[var(--color-sb-border)]',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@ -0,0 +1,88 @@
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '../../lib/cn';
const Sheet = DialogPrimitive.Root;
const SheetTrigger = DialogPrimitive.Trigger;
const SheetClose = DialogPrimitive.Close;
const SheetPortal = DialogPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
side?: 'top' | 'bottom' | 'left' | 'right';
}
const SheetContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ side = 'left', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed z-50 flex flex-col gap-4 bg-[var(--color-sb-bg)] shadow-lg transition ease-in-out',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-300 data-[state=closed]:duration-200',
side === 'left' &&
'inset-y-0 left-0 h-full w-3/4 max-w-[320px] border-r border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
side === 'right' &&
'inset-y-0 right-0 h-full w-3/4 max-w-[320px] border-l border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
side === 'top' &&
'inset-x-0 top-0 border-b border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
side === 'bottom' &&
'inset-x-0 bottom-0 border-t border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 text-[var(--color-sb-muted)] hover:text-[var(--color-sb-text)]">
<X size={16} />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = DialogPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col gap-2 px-4 pt-4', className)} {...props} />
);
SheetHeader.displayName = 'SheetHeader';
const SheetTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-sm font-semibold text-[var(--color-sb-text-bright)]', className)}
{...props}
/>
));
SheetTitle.displayName = DialogPrimitive.Title.displayName;
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetTitle,
};

View File

@ -0,0 +1,31 @@
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '../../lib/cn';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-[var(--color-sb-cell)] px-3 py-1.5 text-xs text-[var(--color-sb-text)]',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

Some files were not shown because too many files have changed in this diff Show More