Compare commits
45 Commits
3598efe955
...
953b3efd58
| Author | SHA1 | Date | |
|---|---|---|---|
| 953b3efd58 | |||
| afb8218b00 | |||
| ad93d6cbbb | |||
| 49ee8d200b | |||
| 459a233511 | |||
| cc7874b0a3 | |||
| eb16dc307d | |||
| ea563ec997 | |||
| 76c3d35894 | |||
| d171352719 | |||
| 8766069203 | |||
| 63e9cd2ad2 | |||
| 844088aa29 | |||
| e25a661071 | |||
| 1f6c0c3735 | |||
| dc987bd0e4 | |||
| acd5abd12e | |||
| 69adfc5683 | |||
| 165767c3d3 | |||
| 2fddfa54a3 | |||
| 132cefee81 | |||
| 3eb8f3794b | |||
| b1c9534d55 | |||
| 9d6590b59d | |||
| 65f7db3dc5 | |||
| efd5977d9b | |||
| d380eb561f | |||
| d0b03507ad | |||
| 1e6dd909c5 | |||
| 5fd0fd3b29 | |||
| c73412e139 | |||
| cf2b18ea2f | |||
| dca53328bc | |||
| 1242683a21 | |||
| 7e841b7c13 | |||
| cb6171bd70 | |||
| e4df6babc0 | |||
| 76fd811071 | |||
| 0fd327ef85 | |||
| 7e1ebecaa7 | |||
| dbe195d9fb | |||
| 751a30ac51 | |||
| 66a521b2b8 | |||
| 89bfa8c5a5 | |||
| a4040ca5fe |
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
34
backend/docker-entrypoint.sh
Normal file
34
backend/docker-entrypoint.sh
Normal 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 "$@"
|
||||
BIN
backend/ltspice-config/LTspice.ini
Normal file
BIN
backend/ltspice-config/LTspice.ini
Normal file
Binary file not shown.
@ -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",
|
||||
|
||||
0
backend/src/spicebook/chat/__init__.py
Normal file
0
backend/src/spicebook/chat/__init__.py
Normal file
149
backend/src/spicebook/chat/llm.py
Normal file
149
backend/src/spicebook/chat/llm.py
Normal 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
|
||||
@ -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"
|
||||
|
||||
@ -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}'")
|
||||
|
||||
98
backend/src/spicebook/engine/ltspice.py
Normal file
98
backend/src/spicebook/engine/ltspice.py
Normal 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,
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
|
||||
96
backend/src/spicebook/engine/raw_convert.py
Normal file
96
backend/src/spicebook/engine/raw_convert.py
Normal 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,
|
||||
)
|
||||
163
backend/src/spicebook/engine/resistor_colors.py
Normal file
163
backend/src/spicebook/engine/resistor_colors.py
Normal 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)
|
||||
]
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
15
backend/src/spicebook/mcp/__init__.py
Normal file
15
backend/src/spicebook/mcp/__init__.py
Normal 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
|
||||
197
backend/src/spicebook/mcp/tools.py
Normal file
197
backend/src/spicebook/mcp/tools.py
Normal 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"])
|
||||
18
backend/src/spicebook/models/chat.py
Normal file
18
backend/src/spicebook/models/chat.py
Normal 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
|
||||
@ -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):
|
||||
|
||||
109
backend/src/spicebook/routers/chat.py
Normal file
109
backend/src/spicebook/routers/chat.py
Normal 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",
|
||||
},
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
|
||||
127
backend/tests/test_ltspice.py
Normal file
127
backend/tests/test_ltspice.py
Normal 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
796
backend/uv.lock
generated
@ -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" },
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
6
docs-site/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
.env
|
||||
.git
|
||||
*.md
|
||||
10
docs-site/.env.example
Normal file
10
docs-site/.env.example
Normal 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
4
docs-site/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
.env
|
||||
68
docs-site/Dockerfile
Normal file
68
docs-site/Dockerfile
Normal 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
50
docs-site/Makefile
Normal 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 ':'
|
||||
79
docs-site/astro.config.mjs
Normal file
79
docs-site/astro.config.mjs
Normal 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,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
19
docs-site/docker-compose.dev.yml
Normal file
19
docs-site/docker-compose.dev.yml
Normal 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"
|
||||
15
docs-site/docker-compose.prod.yml
Normal file
15
docs-site/docker-compose.prod.yml
Normal 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
|
||||
24
docs-site/docker-compose.yml
Normal file
24
docs-site/docker-compose.yml
Normal 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
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
19
docs-site/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
35
docs-site/patches/fix-starlight-head.mjs
Normal file
35
docs-site/patches/fix-starlight-head.mjs
Normal 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);
|
||||
}
|
||||
4
docs-site/public/robots.txt
Normal file
4
docs-site/public/robots.txt
Normal file
@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://spicebook-docs.warehack.ing/sitemap-index.xml
|
||||
7
docs-site/src/content.config.ts
Normal file
7
docs-site/src/content.config.ts
Normal 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() }),
|
||||
};
|
||||
120
docs-site/src/content/docs/api/cells.mdx
Normal file
120
docs-site/src/content/docs/api/cells.mdx
Normal 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.
|
||||
149
docs-site/src/content/docs/api/notebooks.mdx
Normal file
149
docs-site/src/content/docs/api/notebooks.mdx
Normal 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>
|
||||
66
docs-site/src/content/docs/api/overview.mdx
Normal file
66
docs-site/src/content/docs/api/overview.mdx
Normal 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>
|
||||
113
docs-site/src/content/docs/api/simulation.mdx
Normal file
113
docs-site/src/content/docs/api/simulation.mdx
Normal 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.
|
||||
129
docs-site/src/content/docs/api/waveforms-schematics.mdx
Normal file
129
docs-site/src/content/docs/api/waveforms-schematics.mdx
Normal 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
|
||||
```
|
||||
54
docs-site/src/content/docs/embedding/embed-notebook.mdx
Normal file
54
docs-site/src/content/docs/embedding/embed-notebook.mdx
Normal 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.
|
||||
77
docs-site/src/content/docs/embedding/theme-sync.mdx
Normal file
77
docs-site/src/content/docs/embedding/theme-sync.mdx
Normal 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.
|
||||
59
docs-site/src/content/docs/getting-started/editor-basics.mdx
Normal file
59
docs-site/src/content/docs/getting-started/editor-basics.mdx
Normal 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
|
||||
67
docs-site/src/content/docs/getting-started/quick-start.mdx
Normal file
67
docs-site/src/content/docs/getting-started/quick-start.mdx
Normal 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
|
||||
50
docs-site/src/content/docs/index.mdx
Normal file
50
docs-site/src/content/docs/index.mdx
Normal 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>
|
||||
62
docs-site/src/content/docs/schematics/auto-generated.mdx
Normal file
62
docs-site/src/content/docs/schematics/auto-generated.mdx
Normal 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>
|
||||
71
docs-site/src/content/docs/schematics/resistor-colors.mdx
Normal file
71
docs-site/src/content/docs/schematics/resistor-colors.mdx
Normal 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.
|
||||
60
docs-site/src/content/docs/simulation/engines.mdx
Normal file
60
docs-site/src/content/docs/simulation/engines.mdx
Normal 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.
|
||||
118
docs-site/src/content/docs/simulation/running.mdx
Normal file
118
docs-site/src/content/docs/simulation/running.mdx
Normal 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` |
|
||||
76
docs-site/src/content/docs/simulation/waveforms.mdx
Normal file
76
docs-site/src/content/docs/simulation/waveforms.mdx
Normal 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.
|
||||
43
docs-site/src/styles/custom.css
Normal file
43
docs-site/src/styles/custom.css
Normal 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
5
docs-site/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
967
docs/reference-architecture-mcp-chat.md
Normal file
967
docs/reference-architecture-mcp-chat.md
Normal 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__*"
|
||||
```
|
||||
@ -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
18
frontend/components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
958
frontend/package-lock.json
generated
958
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
BIN
frontend/public/audio/spicebook-lightning-talk.mp3
Normal file
BIN
frontend/public/audio/spicebook-lightning-talk.mp3
Normal file
Binary file not shown.
@ -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.
|
||||
|
||||
BIN
frontend/public/n-spheres/attractor.mp3
Normal file
BIN
frontend/public/n-spheres/attractor.mp3
Normal file
Binary file not shown.
BIN
frontend/public/n-spheres/core.mp3
Normal file
BIN
frontend/public/n-spheres/core.mp3
Normal file
Binary file not shown.
BIN
frontend/public/n-spheres/flux.mp3
Normal file
BIN
frontend/public/n-spheres/flux.mp3
Normal file
Binary file not shown.
BIN
frontend/public/n-spheres/function.mp3
Normal file
BIN
frontend/public/n-spheres/function.mp3
Normal file
Binary file not shown.
BIN
frontend/public/n-spheres/intersect.mp3
Normal file
BIN
frontend/public/n-spheres/intersect.mp3
Normal file
Binary file not shown.
BIN
frontend/public/spirals_shrt.mp3
Normal file
BIN
frontend/public/spirals_shrt.mp3
Normal file
Binary file not shown.
@ -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',
|
||||
]}
|
||||
>
|
||||
|
||||
@ -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" />
|
||||
|
||||
605
frontend/src/components/OscilloscopeDisplay.astro
Normal file
605
frontend/src/components/OscilloscopeDisplay.astro
Normal 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"
|
||||
>⏻ 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 = `“<a href="${signal.link}" target="_blank" rel="noopener">${signal.name}</a>” 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 = `“${signal.name}” 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 = '⏻ 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 = '⏻ 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>
|
||||
@ -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>
|
||||
|
||||
92
frontend/src/components/RecentNotebooks.astro
Normal file
92
frontend/src/components/RecentNotebooks.astro
Normal 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>·</span>
|
||||
<span>{formatDate(nb.modified)}</span>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
199
frontend/src/components/ResistorColorGuide.astro
Normal file
199
frontend/src/components/ResistorColorGuide.astro
Normal 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Ω</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Ω ±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>
|
||||
91
frontend/src/components/chat/ChatInput.tsx
Normal file
91
frontend/src/components/chat/ChatInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/chat/ChatMessages.tsx
Normal file
84
frontend/src/components/chat/ChatMessages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
frontend/src/components/chat/ChatPage.tsx
Normal file
129
frontend/src/components/chat/ChatPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/chat/ChatSidebar.tsx
Normal file
111
frontend/src/components/chat/ChatSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
frontend/src/components/chat/ChatWidget.tsx
Normal file
306
frontend/src/components/chat/ChatWidget.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
97
frontend/src/components/chat/NotebookPicker.tsx
Normal file
97
frontend/src/components/chat/NotebookPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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' : ''}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
108
frontend/src/components/notebook/toolbar/EmbedDialog.tsx
Normal file
108
frontend/src/components/notebook/toolbar/EmbedDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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'}
|
||||
|
||||
111
frontend/src/components/ui/command.tsx
Normal file
111
frontend/src/components/ui/command.tsx
Normal 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,
|
||||
};
|
||||
32
frontend/src/components/ui/popover.tsx
Normal file
32
frontend/src/components/ui/popover.tsx
Normal 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 };
|
||||
43
frontend/src/components/ui/scroll-area.tsx
Normal file
43
frontend/src/components/ui/scroll-area.tsx
Normal 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 };
|
||||
23
frontend/src/components/ui/separator.tsx
Normal file
23
frontend/src/components/ui/separator.tsx
Normal 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 };
|
||||
88
frontend/src/components/ui/sheet.tsx
Normal file
88
frontend/src/components/ui/sheet.tsx
Normal 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,
|
||||
};
|
||||
31
frontend/src/components/ui/tooltip.tsx
Normal file
31
frontend/src/components/ui/tooltip.tsx
Normal 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
Loading…
x
Reference in New Issue
Block a user