Compare commits

..

No commits in common. "c0639c775c09633a5c367ef294a900ce5b108ed6" and "09fd59d5708bf9ebadc078b2980a490c85f73887" have entirely different histories.

48 changed files with 30 additions and 6287 deletions

View File

@ -12,23 +12,14 @@ Notebook interface for SPICE circuit simulation.
```bash
# Local dev (Docker)
make dev
# Local site: https://spicebook.l.warehack.ing (via caddy-docker-proxy)
# API docs: https://spicebook.l.warehack.ing/docs
# Frontend: http://localhost:4321
# Backend: http://localhost:8099
# 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.

View File

@ -12,8 +12,6 @@ dependencies = [
"numpy>=1.24.0",
"websockets>=12.0",
"schemdraw>=0.19",
"fastmcp>=3.0.0",
"httpx>=0.28.0",
]
[project.optional-dependencies]

View File

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

View File

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

View File

@ -1,15 +1,13 @@
"""SPICE simulation engine registry."""
from fastapi import HTTPException
from spicebook.engine.base import SpiceEngine
from spicebook.engine.ngspice import NgspiceEngine
class UnsupportedEngineError(ValueError):
"""Raised when an unknown engine name is requested."""
def get_engine(engine_name: str) -> SpiceEngine:
"""Resolve a simulation engine by name."""
if engine_name == "ngspice":
return NgspiceEngine()
raise UnsupportedEngineError(f"Unsupported engine: '{engine_name}'")
raise HTTPException(status_code=400, detail=f"Unsupported engine: '{engine_name}'")

View File

@ -638,7 +638,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
d.add(
_get_element(source, parsed.models)
.up()
.label(_component_label(source), loc="left", ofst=0.15)
.label(_component_label(source), loc="left")
)
d.add(elm.Ground())
return d.get_imagedata("svg").decode()
@ -647,7 +647,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
src = d.add(
_get_element(source, parsed.models)
.up()
.label(_component_label(source), loc="left", ofst=0.15)
.label(_component_label(source), loc="left")
)
# All components except the last go right across the top
@ -655,7 +655,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
d.add(
_get_element(comp, parsed.models)
.right()
.label(_component_label(comp), ofst=0.15)
.label(_component_label(comp))
)
# Last component goes down, ending at the same y-level as the source start
@ -664,7 +664,7 @@ def _render_loop(parsed: ParsedNetlist, loop: list[SpiceComponent]) -> str:
_get_element(last_comp, parsed.models)
.down()
.toy(src.start)
.label(_component_label(last_comp), loc="right", ofst=0.15)
.label(_component_label(last_comp), loc="right")
)
# Return wire along the bottom back to source start
@ -1009,7 +1009,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), loc="right", ofst=0.15)
elem.at((x, center_y)).label(_component_label(comp))
)
elif len(comp.nodes) >= 2:
# 2-terminal: orient vertically between the two node tiers
@ -1199,7 +1199,7 @@ def _draw_horiz_then_down(d, parsed, start, path, going_right):
)
break
else:
d.add(getattr(elem, h_dir)().label(_component_label(comp), ofst=0.15))
d.add(getattr(elem, h_dir)().label(_component_label(comp)))
# Single-component horizontal path ending at ground/supply/open
if len(comps) == 1:
@ -1228,7 +1228,7 @@ 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", ofst=0.15))
q = d.add(dev_elem.label(_component_label(layout.device), loc="right"))
anchors = {
"collector": q.collector,
"base": q.base,
@ -1238,7 +1238,7 @@ def _render_connected(parsed: ParsedNetlist, layout: ActiveLayout) -> str:
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", ofst=0.15))
q = d.add(dev_elem.label(_component_label(layout.device), loc="right"))
anchors = {
"drain": q.drain,
"gate": q.gate,

View File

@ -4,39 +4,22 @@ 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.mcp import mcp
from spicebook.routers import chat, compose, notebooks, schematics, simulation, waveforms
from spicebook.routers import compose, notebooks, schematics, simulation, waveforms
logger = logging.getLogger("spicebook")
@asynccontextmanager
async def lifespan(app: FastAPI):
_configure_logging()
_validate_ngspice()
_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
@ -83,14 +66,16 @@ 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"}
# Mount MCP server at /mcp
application.mount("/mcp", mcp_app)
@application.on_event("startup")
async def startup():
_configure_logging()
_validate_ngspice()
_ensure_directories()
return application

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from spicebook.config import settings
from spicebook.engine import UnsupportedEngineError, get_engine
from spicebook.engine import 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,10 +18,7 @@ router = APIRouter(prefix="/api", tags=["simulation"])
@router.post("/simulate", response_model=SimulationResponse)
async def simulate(req: SimulationRequest):
"""Run a standalone SPICE simulation."""
try:
engine = get_engine(req.engine)
except UnsupportedEngineError as exc:
raise HTTPException(status_code=400, detail=str(exc))
engine = get_engine(req.engine)
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
result = await engine.run(req.netlist, Path(tmpdir))
@ -57,10 +54,7 @@ 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")
try:
engine = get_engine(nb.metadata.engine)
except UnsupportedEngineError as exc:
raise HTTPException(status_code=400, detail=str(exc))
engine = get_engine(nb.metadata.engine)
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
result = await engine.run(cell.source, Path(tmpdir))

View File

@ -19,18 +19,6 @@ 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()
@ -135,7 +123,6 @@ 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():
@ -145,7 +132,6 @@ 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)
@ -188,7 +174,6 @@ 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()

772
backend/uv.lock generated
View File

@ -2,18 +2,6 @@ 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"
@ -45,60 +33,6 @@ 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/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/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/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"
@ -108,63 +42,6 @@ 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"
@ -186,126 +63,6 @@ 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"
@ -322,37 +79,6 @@ 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"
@ -419,15 +145,6 @@ 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"
@ -437,18 +154,6 @@ 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"
@ -458,170 +163,6 @@ 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 = "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"
@ -683,31 +224,6 @@ 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"
@ -717,24 +233,6 @@ 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"
@ -744,40 +242,6 @@ 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"
@ -793,11 +257,6 @@ 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"
@ -869,20 +328,6 @@ 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"
@ -892,29 +337,6 @@ 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"
@ -953,40 +375,6 @@ 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"
@ -1033,127 +421,6 @@ 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"
@ -1188,27 +455,12 @@ 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" },
@ -1227,8 +479,6 @@ dev = [
[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 = "numpy", specifier = ">=1.24.0" },
{ name = "pydantic", specifier = ">=2.0" },
@ -1241,19 +491,6 @@ requires-dist = [
]
provides-extras = ["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"
version = "0.52.1"
@ -1458,12 +695,3 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]

View File

@ -19,14 +19,8 @@ services:
- caddy
labels:
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc /mcp/*"
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc"
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:

View File

@ -11,14 +11,8 @@ services:
- caddy
labels:
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc /mcp/*"
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc"
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:

View File

@ -15,8 +15,6 @@ services:
build:
context: ./frontend
env_file: .env
environment:
- PUBLIC_API_URL=https://${SPICEBOOK_DOMAIN:-localhost:4321}
depends_on:
backend:
condition: service_healthy

View File

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

View File

@ -24,10 +24,6 @@ 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,

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -23,24 +23,13 @@
"@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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,31 +0,0 @@
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 };

View File

@ -1,67 +0,0 @@
---
import { Seo } from 'astro-seo-meta';
import type { SEOProps } from '../lib/seo';
import { SEO_DEFAULTS } from '../lib/seo';
interface Props extends SEOProps {
title?: string;
}
const {
title = 'Chat | SpiceBook',
description,
ogImage,
ogType = 'website',
noindex = false,
canonicalPath,
} = Astro.props;
const pageTitle = title === 'SpiceBook' ? title : `${title} | SpiceBook`;
const pageDescription = description || SEO_DEFAULTS.defaultDescription;
const ogImageUrl = `${SEO_DEFAULTS.siteUrl}${ogImage || SEO_DEFAULTS.defaultOgImage}`;
const canonicalUrl = canonicalPath
? `${SEO_DEFAULTS.siteUrl}${canonicalPath}`
: undefined;
---
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
<Seo
title={pageTitle}
description={pageDescription}
icon="/favicon.svg"
colorScheme="dark"
robots={noindex ? 'noindex, nofollow' : 'index, follow'}
facebook={{
image: ogImageUrl,
url: canonicalUrl || SEO_DEFAULTS.siteUrl,
type: ogType,
}}
twitter={{
image: ogImageUrl,
card: 'summary_large_image',
}}
/>
<meta property="og:site_name" content={SEO_DEFAULTS.siteName} />
</head>
<body class="bg-slate-950 text-slate-200 min-h-screen antialiased">
<slot />
<script>
// Clean up stale service workers — SpiceBook doesn't use one
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then((regs) => {
for (const reg of regs) reg.unregister();
});
}
</script>
</body>
</html>
<style is:global>
@import '../styles/globals.css';
</style>

View File

@ -2,7 +2,6 @@
import { Seo } from 'astro-seo-meta';
import type { SEOProps } from '../lib/seo';
import { SEO_DEFAULTS } from '../lib/seo';
import ChatWidget from '../components/chat/ChatWidget';
interface Props extends SEOProps {
title?: string;
@ -52,7 +51,6 @@ const canonicalUrl = canonicalPath
</head>
<body class="bg-slate-950 text-slate-200 min-h-screen antialiased">
<slot />
<ChatWidget client:load />
<script>
// Clean up stale service workers — SpiceBook doesn't use one
if ('serviceWorker' in navigator) {

View File

@ -7,7 +7,7 @@ import type {
CreateNotebookResponse,
} from './types';
export const API_BASE = (() => {
const API_BASE = (() => {
// PUBLIC_API_URL controls where API requests go:
// - Dev (local): 'http://localhost:8099'
// - Production: '' (empty = same origin, Caddy routes /api/* to backend)

View File

@ -1,86 +0,0 @@
/**
* SSE client for the SpiceBook chat streaming endpoint.
* Yields parsed server-sent events as an async generator.
*/
import { API_BASE } from './api';
export interface SSEEvent {
event: string;
data: Record<string, unknown>;
}
export interface ChatStreamOptions {
question: string;
notebook?: {
notebook_id: string;
title: string;
engine: string;
} | null;
signal?: AbortSignal;
}
function parseSSEBlock(raw: string): SSEEvent | null {
let event = '';
let data = '';
for (const line of raw.split('\n')) {
if (line.startsWith('event: ')) {
event = line.slice(7);
} else if (line.startsWith('data: ')) {
data = line.slice(6);
}
}
if (!event || !data) return null;
try {
return { event, data: JSON.parse(data) };
} catch {
return null;
}
}
export async function* streamChat(opts: ChatStreamOptions): AsyncGenerator<SSEEvent> {
const body: Record<string, unknown> = { question: opts.question };
if (opts.notebook) {
body.notebook = opts.notebook;
}
const resp = await fetch(`${API_BASE}/api/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: opts.signal,
});
if (!resp.ok) {
throw new Error(`Chat request failed: HTTP ${resp.status}`);
}
const reader = resp.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let boundary: number;
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
const raw = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const evt = parseSSEBlock(raw);
if (evt) yield evt;
}
}
} finally {
reader.releaseLock();
}
}

View File

@ -1,68 +0,0 @@
/**
* Shared markdown + KaTeX rendering for SpiceBook chat.
* Used by both the floating ChatWidget and the full /chat page.
*/
import { marked } from 'marked';
import markedKatex from 'marked-katex-extension';
import DOMPurify from 'dompurify';
// ── Configure marked once at module load ─────────────────
marked.setOptions({
breaks: true,
gfm: true,
});
marked.use(markedKatex({
throwOnError: false,
nonStandard: true,
}));
// ── KaTeX allow-lists for DOMPurify ─────────────────────
export const KATEX_TAGS = [
'math', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub',
'mfrac', 'munderover', 'mover', 'munder', 'msqrt', 'mroot',
'mtable', 'mtr', 'mtd', 'mspace', 'mtext', 'menclose',
'annotation', 'annotation-xml',
];
export const KATEX_ATTRS = [
'xmlns', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel',
'fence', 'stretchy', 'symmetric', 'lspace', 'rspace',
'linethickness', 'columnalign', 'rowalign', 'columnspacing',
'rowspacing', 'columnlines', 'rowlines', 'frame', 'framespacing',
'width', 'height', 'voffset', 'accent', 'accentunder',
'notation', 'minsize', 'maxsize', 'movablelimits',
'aria-hidden', 'focusable', 'role', 'tabindex',
'viewBox', 'preserveAspectRatio', 'd', 'fill', 'stroke',
'stroke-width', 'stroke-linecap', 'stroke-linejoin',
'transform', 'clip-path',
];
// ── Rendering functions ─────────────────────────────────
/**
* LLMs often emit $$...$$ inline (e.g. "is:$$\frac{1}{s}$$\nNext")
* but marked-katex-extension requires $$ on its own line for display mode.
* This normalizer ensures $$ delimiters get their own lines.
*/
export function normalizeDisplayMath(text: string): string {
return text.replace(/\$\$([\s\S]*?)\$\$/g, (_match, inner: string) => {
const trimmed = inner.trim();
return `\n$$\n${trimmed}\n$$\n`;
});
}
/**
* Render markdown text to sanitized HTML with KaTeX support.
*/
export function renderMarkdown(text: string): string {
const normalized = normalizeDisplayMath(text);
const raw = marked.parse(normalized, { async: false }) as string;
return DOMPurify.sanitize(raw, {
ADD_TAGS: KATEX_TAGS,
ADD_ATTR: [...KATEX_ATTRS, 'target', 'class', 'style'],
});
}

View File

@ -1,189 +0,0 @@
/**
* Zustand store for SpiceBook chat conversations.
* Persists to localStorage with conversation history management.
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface ChatMessage {
role: 'user' | 'assistant';
text: string;
timestamp: number;
}
export interface Conversation {
id: string;
title: string;
messages: ChatMessage[];
createdAt: number;
updatedAt: number;
}
const MAX_CONVERSATIONS = 20;
const MAX_MESSAGES = 50;
export interface SelectedNotebookMeta {
title: string;
engine: string;
}
interface ChatStore {
conversations: Conversation[];
activeConversationId: string | null;
panelOpen: boolean;
streaming: boolean;
selectedNotebookId: string | null;
selectedNotebookMeta: SelectedNotebookMeta | null;
// Actions
openPanel: () => void;
closePanel: () => void;
togglePanel: () => void;
createConversation: () => string;
setActiveConversation: (id: string) => void;
addUserMessage: (text: string) => void;
addAssistantMessage: (text: string) => void;
appendToLastAssistant: (text: string) => void;
setStreaming: (streaming: boolean) => void;
deleteConversation: (id: string) => void;
getActiveConversation: () => Conversation | null;
setSelectedNotebook: (id: string, meta: SelectedNotebookMeta) => void;
clearSelectedNotebook: () => void;
}
function generateId(): string {
return crypto.randomUUID().slice(0, 8);
}
function titleFromQuestion(question: string): string {
const trimmed = question.trim().slice(0, 50);
return trimmed.length < question.trim().length ? `${trimmed}` : trimmed;
}
export const useChatStore = create<ChatStore>()(
persist(
(set, get) => ({
conversations: [],
activeConversationId: null,
panelOpen: false,
streaming: false,
selectedNotebookId: null,
selectedNotebookMeta: null,
openPanel: () => set({ panelOpen: true }),
closePanel: () => set({ panelOpen: false }),
togglePanel: () => set((s) => ({ panelOpen: !s.panelOpen })),
createConversation: () => {
const id = generateId();
const now = Date.now();
const conv: Conversation = {
id,
title: 'New conversation',
messages: [],
createdAt: now,
updatedAt: now,
};
set((s) => {
const conversations = [conv, ...s.conversations].slice(0, MAX_CONVERSATIONS);
return { conversations, activeConversationId: id };
});
return id;
},
setActiveConversation: (id: string) => set({ activeConversationId: id }),
addUserMessage: (text: string) => {
const now = Date.now();
set((s) => {
const convId = s.activeConversationId;
if (!convId) return s;
return {
conversations: s.conversations.map((c) => {
if (c.id !== convId) return c;
const messages = [...c.messages, { role: 'user' as const, text, timestamp: now }]
.slice(-MAX_MESSAGES);
const title = c.messages.length === 0 ? titleFromQuestion(text) : c.title;
return { ...c, messages, title, updatedAt: now };
}),
};
});
},
addAssistantMessage: (text: string) => {
const now = Date.now();
set((s) => {
const convId = s.activeConversationId;
if (!convId) return s;
return {
conversations: s.conversations.map((c) => {
if (c.id !== convId) return c;
const messages = [
...c.messages,
{ role: 'assistant' as const, text, timestamp: now },
].slice(-MAX_MESSAGES);
return { ...c, messages, updatedAt: now };
}),
};
});
},
appendToLastAssistant: (text: string) => {
set((s) => {
const convId = s.activeConversationId;
if (!convId) return s;
return {
conversations: s.conversations.map((c) => {
if (c.id !== convId) return c;
const messages = [...c.messages];
const last = messages[messages.length - 1];
if (last && last.role === 'assistant') {
messages[messages.length - 1] = { ...last, text: last.text + text };
}
return { ...c, messages, updatedAt: Date.now() };
}),
};
});
},
setStreaming: (streaming: boolean) => set({ streaming }),
deleteConversation: (id: string) => {
set((s) => {
const conversations = s.conversations.filter((c) => c.id !== id);
const activeConversationId =
s.activeConversationId === id
? conversations[0]?.id ?? null
: s.activeConversationId;
return { conversations, activeConversationId };
});
},
getActiveConversation: () => {
const s = get();
return s.conversations.find((c) => c.id === s.activeConversationId) ?? null;
},
setSelectedNotebook: (id: string, meta: SelectedNotebookMeta) =>
set({ selectedNotebookId: id, selectedNotebookMeta: meta }),
clearSelectedNotebook: () =>
set({ selectedNotebookId: null, selectedNotebookMeta: null }),
}),
{
name: 'spicebook-chat',
partialize: (state) => ({
conversations: state.conversations,
activeConversationId: state.activeConversationId,
selectedNotebookId: state.selectedNotebookId,
selectedNotebookMeta: state.selectedNotebookMeta,
}),
},
),
);

View File

@ -1,176 +0,0 @@
/**
* Shared streaming hook for SpiceBook chat.
* Handles SSE event dispatch, RAF token batching, abort, and reasoning state.
* Used by both the floating ChatWidget and the full /chat page.
*/
import { useCallback, useRef, useState } from 'react';
import { useChatStore } from './chat-store';
import { useNotebookStore } from './notebook-store';
import { streamChat } from './chat-api';
export interface ChatStreamState {
statusText: string;
reasoningText: string;
reasoningTime: number;
}
export interface UseChatStreamReturn extends ChatStreamState {
/** Send a message. Creates a conversation if none is active. */
sendMessage: (text: string) => Promise<void>;
/** Abort the current stream. */
abort: () => void;
/** Whether a stream is currently in progress. */
streaming: boolean;
}
interface UseChatStreamOptions {
/** Override notebook context (for the /chat page's notebook picker). */
notebookOverride?: {
notebook_id: string;
title: string;
engine: string;
} | null;
}
export function useChatStream(opts?: UseChatStreamOptions): UseChatStreamReturn {
const streaming = useChatStore((s) => s.streaming);
const activeConversationId = useChatStore((s) => s.activeConversationId);
const createConversation = useChatStore((s) => s.createConversation);
const addUserMessage = useChatStore((s) => s.addUserMessage);
const addAssistantMessage = useChatStore((s) => s.addAssistantMessage);
const appendToLastAssistant = useChatStore((s) => s.appendToLastAssistant);
const setStreaming = useChatStore((s) => s.setStreaming);
// Notebook context from the notebook store (widget on notebook pages)
const notebookId = useNotebookStore((s) => s.notebookId);
const notebook = useNotebookStore((s) => s.notebook);
const [statusText, setStatusText] = useState('');
const [reasoningText, setReasoningText] = useState('');
const [reasoningTime, setReasoningTime] = useState(0);
const abortRef = useRef<AbortController | null>(null);
// Token batching: accumulate tokens in a ref, flush to state once per
// animation frame. Without this, React 19 batches all rapid set() calls
// from the SSE loop into one render at stream end — so the user sees
// nothing until the LLM finishes.
const pendingTokensRef = useRef('');
const flushRafRef = useRef(0);
const abort = useCallback(() => {
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
setStreaming(false);
}
}, [setStreaming]);
const sendMessage = useCallback(async (text: string) => {
const question = text.trim();
if (!question || streaming) return;
// Ensure there's an active conversation
if (!activeConversationId) {
createConversation();
}
addUserMessage(question);
addAssistantMessage('');
setStreaming(true);
setStatusText('');
setReasoningText('');
setReasoningTime(0);
const abortController = new AbortController();
abortRef.current = abortController;
const reasoningStart = Date.now();
try {
// Notebook context: prefer explicit override (from picker), fall back
// to the notebook store (when widget is on a notebook page)
const notebookCtx = opts?.notebookOverride
?? (notebookId && notebook
? {
notebook_id: notebookId,
title: notebook.metadata.title,
engine: notebook.metadata.engine,
}
: null);
for await (const evt of streamChat({
question,
notebook: notebookCtx,
signal: abortController.signal,
})) {
switch (evt.event) {
case 'status':
setStatusText((evt.data as { text: string }).text);
break;
case 'token':
// Accumulate in ref; flush to Zustand once per animation frame
pendingTokensRef.current += (evt.data as { text: string }).text;
if (!flushRafRef.current) {
flushRafRef.current = requestAnimationFrame(() => {
if (pendingTokensRef.current) {
appendToLastAssistant(pendingTokensRef.current);
pendingTokensRef.current = '';
}
flushRafRef.current = 0;
});
setStatusText('');
}
break;
case 'reasoning':
setReasoningText((prev) => prev + (evt.data as { text: string }).text);
setReasoningTime(Math.round((Date.now() - reasoningStart) / 1000));
break;
case 'error':
appendToLastAssistant(
`\n\n*Error: ${(evt.data as { text: string }).text}*`,
);
break;
case 'done':
break;
}
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
// User cancelled — that's fine
} else {
appendToLastAssistant(
`\n\n*Error: ${err instanceof Error ? err.message : 'Connection failed'}*`,
);
}
} finally {
// Flush any remaining buffered tokens
if (flushRafRef.current) {
cancelAnimationFrame(flushRafRef.current);
flushRafRef.current = 0;
}
if (pendingTokensRef.current) {
appendToLastAssistant(pendingTokensRef.current);
pendingTokensRef.current = '';
}
setStreaming(false);
setStatusText('');
abortRef.current = null;
}
}, [
streaming, activeConversationId, notebookId, notebook,
opts?.notebookOverride,
createConversation, addUserMessage, addAssistantMessage,
appendToLastAssistant, setStreaming,
]);
return {
sendMessage,
abort,
streaming,
statusText,
reasoningText,
reasoningTime,
};
}

View File

@ -1,8 +0,0 @@
---
import ChatLayout from '../layouts/ChatLayout.astro';
import ChatPage from '../components/chat/ChatPage';
---
<ChatLayout title="Chat" description="Full-page circuit assistant with conversation history and notebook context" canonicalPath="/chat">
<ChatPage client:load />
</ChatLayout>

View File

@ -72,7 +72,7 @@ try {
<PipelineStrip />
</section>
<!-- Reference links -->
<!-- Resistor Color Code Reference link -->
<section class="section-fade">
<div class="max-w-6xl mx-auto px-6 py-6 flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
<a href="/reference/resistor-colors"
@ -103,35 +103,6 @@ try {
</div>
<Icon name="lucide:chevron-right" class="w-4 h-4 text-slate-600 group-hover:text-slate-400 transition-colors" />
</a>
<a href="https://mcltspice.warehack.ing" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-4 px-5 py-3 rounded-lg border border-slate-700
hover:border-slate-500 hover:bg-slate-800/40 transition-colors group">
<!-- Inline IC chip SVG: DIP package with 6 pin traces -->
<svg viewBox="0 0 120 32" class="w-24 h-8 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" aria-label="IC chip representing MCP tools">
<!-- Left pin traces -->
<line x1="0" y1="8" x2="28" y2="8" stroke="rgba(20,184,166,0.5)" stroke-width="1.5"/>
<line x1="0" y1="16" x2="28" y2="16" stroke="rgba(20,184,166,0.5)" stroke-width="1.5"/>
<line x1="0" y1="24" x2="28" y2="24" stroke="rgba(20,184,166,0.5)" stroke-width="1.5"/>
<!-- Chip body -->
<rect x="28" y="3" width="64" height="26" rx="3" ry="3" fill="rgba(20,184,166,0.08)" stroke="rgba(20,184,166,0.6)" stroke-width="1.5"/>
<!-- Pin-1 notch -->
<path d="M28,12 A4,4 0 0,1 28,20" fill="none" stroke="rgba(20,184,166,0.4)" stroke-width="1"/>
<!-- Right pin traces -->
<line x1="92" y1="8" x2="120" y2="8" stroke="rgba(20,184,166,0.5)" stroke-width="1.5"/>
<line x1="92" y1="16" x2="120" y2="16" stroke="rgba(20,184,166,0.5)" stroke-width="1.5"/>
<line x1="92" y1="24" x2="120" y2="24" stroke="rgba(20,184,166,0.5)" stroke-width="1.5"/>
</svg>
<div>
<span class="text-sm font-semibold text-slate-200 group-hover:text-slate-100">
mcltspice MCP Server
</span>
<span class="block text-xs text-slate-500">
37 tools for LTspice automation
</span>
</div>
<Icon name="lucide:chevron-right" class="w-4 h-4 text-slate-600 group-hover:text-slate-400 transition-colors" />
</a>
</div>
</section>

View File

@ -1,657 +0,0 @@
/* SpiceBook Full Chat Page — dark-only, full-viewport layout */
/* ── Root layout ─────────────────────────────────────── */
.chat-page-root {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--color-sb-bg);
}
/* ── Header ──────────────────────────────────────────── */
.chat-page-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 3rem;
padding: 0 1rem;
border-bottom: 1px solid var(--color-sb-border);
background: var(--color-sb-surface);
flex-shrink: 0;
}
.chat-page-logo {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-sb-text-bright);
text-decoration: none;
transition: opacity 0.15s;
}
.chat-page-logo:hover {
opacity: 0.8;
}
.chat-page-header-title {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-sb-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 50%;
text-align: center;
}
.chat-page-menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: var(--color-sb-muted);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.chat-page-menu-btn:hover {
background: var(--color-sb-cell);
color: var(--color-sb-text);
}
/* ── Body (sidebar + main) ───────────────────────────── */
.chat-page-body {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Desktop sidebar ─────────────────────────────────── */
.chat-page-desktop-sidebar {
display: none;
width: 280px;
flex-shrink: 0;
border-right: 1px solid var(--color-sb-border);
background: var(--color-sb-surface);
}
@media (min-width: 768px) {
.chat-page-desktop-sidebar {
display: block;
}
}
/* ── Sidebar internals ───────────────────────────────── */
.chat-page-sidebar {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chat-page-sidebar-header {
padding: 0.75rem;
flex-shrink: 0;
}
.chat-page-new-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: 1px dashed var(--color-sb-border);
background: transparent;
color: var(--color-sb-text);
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.1s, border-color 0.15s;
}
.chat-page-new-btn:hover {
background: var(--color-sb-cell);
border-color: var(--color-sb-accent);
color: var(--color-sb-text-bright);
}
.chat-page-sidebar-search {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0.75rem 0.5rem;
padding: 0.375rem 0.625rem;
border: 1px solid var(--color-sb-border);
border-radius: 0.375rem;
background: var(--color-sb-bg);
}
.chat-page-sidebar-search-input {
flex: 1;
border: none;
background: transparent;
color: var(--color-sb-text);
font-size: 0.8125rem;
outline: none;
font-family: var(--font-sans);
}
.chat-page-sidebar-search-input::placeholder {
color: var(--color-sb-muted);
}
/* ── Conversation list ───────────────────────────────── */
.chat-page-conv-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0 0.5rem;
}
.chat-page-conv-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid transparent;
background: transparent;
color: var(--color-sb-text);
text-align: left;
cursor: pointer;
transition: background 0.1s;
width: 100%;
}
.chat-page-conv-item:hover {
background: var(--color-sb-cell);
}
.chat-page-conv-item.active {
background: var(--color-sb-cell);
border-color: var(--color-sb-border);
}
.chat-page-conv-delete {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
color: var(--color-sb-muted);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
}
.chat-page-conv-item:hover .chat-page-conv-delete {
opacity: 1;
}
.chat-page-conv-delete:hover {
color: var(--color-sb-danger);
}
.chat-page-conv-delete.confirm {
color: var(--color-sb-danger);
opacity: 1;
}
/* ── Notebook picker ─────────────────────────────────── */
.chat-page-notebook-picker {
padding: 0.75rem;
flex-shrink: 0;
}
.chat-page-notebook-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid var(--color-sb-border);
background: var(--color-sb-bg);
color: var(--color-sb-text);
font-size: 0.8125rem;
cursor: pointer;
transition: border-color 0.15s;
font-family: var(--font-sans);
}
.chat-page-notebook-btn:hover {
border-color: var(--color-sb-accent);
}
.chat-page-notebook-clear {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
color: var(--color-sb-muted);
transition: color 0.1s, background 0.1s;
}
.chat-page-notebook-clear:hover {
color: var(--color-sb-text-bright);
background: var(--color-sb-cell);
}
/* ── Main message area ───────────────────────────────── */
.chat-page-main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
overflow: hidden;
}
/* ── Messages ────────────────────────────────────────── */
.chat-page-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.chat-page-messages::-webkit-scrollbar {
width: 6px;
}
.chat-page-messages::-webkit-scrollbar-thumb {
background: var(--color-sb-border);
border-radius: 3px;
}
.chat-page-messages::-webkit-scrollbar-thumb:hover {
background: var(--color-sb-muted);
}
/* Empty state */
.chat-page-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 0.75rem;
padding: 2rem;
}
.chat-page-empty-icon {
color: var(--color-sb-border);
}
/* ── Message bubbles (wider than widget) ─────────────── */
.chat-page-bubble {
max-width: 720px;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
font-size: 0.9375rem;
line-height: 1.65;
word-wrap: break-word;
overflow-wrap: break-word;
}
.chat-page-bubble.user {
align-self: flex-end;
background: var(--color-sb-accent);
color: white;
border-bottom-right-radius: 0.25rem;
}
.chat-page-bubble.assistant {
align-self: flex-start;
background: var(--color-sb-cell);
color: var(--color-sb-text);
border-bottom-left-radius: 0.25rem;
}
/* ── Assistant bubble content styling ────────────────── */
.chat-page-bubble.assistant strong {
color: var(--color-sb-text-bright);
}
.chat-page-bubble.assistant code {
font-family: var(--font-mono);
font-size: 0.8125em;
background: var(--color-sb-code-bg);
padding: 0.125rem 0.375rem;
border-radius: 3px;
color: #93c5fd;
}
.chat-page-bubble.assistant pre {
background: var(--color-sb-code-bg);
border: 1px solid var(--color-sb-border);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
margin: 0.5rem 0;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 0.8125rem;
}
.chat-page-bubble.assistant pre code {
background: transparent;
padding: 0;
}
/* Headings */
.chat-page-bubble.assistant h1,
.chat-page-bubble.assistant h2,
.chat-page-bubble.assistant h3,
.chat-page-bubble.assistant h4 {
color: var(--color-sb-text-bright);
font-weight: 600;
margin: 0.875rem 0 0.375rem;
line-height: 1.3;
}
.chat-page-bubble.assistant h1 { font-size: 1.125rem; }
.chat-page-bubble.assistant h2 { font-size: 1.0625rem; }
.chat-page-bubble.assistant h3 { font-size: 1rem; }
.chat-page-bubble.assistant h4 { font-size: 0.9375rem; }
.chat-page-bubble.assistant h1:first-child,
.chat-page-bubble.assistant h2:first-child,
.chat-page-bubble.assistant h3:first-child,
.chat-page-bubble.assistant h4:first-child {
margin-top: 0;
}
/* Paragraphs */
.chat-page-bubble.assistant p {
margin: 0.5rem 0;
}
.chat-page-bubble.assistant p:first-child {
margin-top: 0;
}
.chat-page-bubble.assistant p:last-child {
margin-bottom: 0;
}
/* Lists */
.chat-page-bubble.assistant ul,
.chat-page-bubble.assistant ol {
margin: 0.5rem 0;
padding-left: 1.5rem;
}
.chat-page-bubble.assistant li {
margin: 0.25rem 0;
}
/* Horizontal rules */
.chat-page-bubble.assistant hr {
border: none;
border-top: 1px solid var(--color-sb-border);
margin: 0.75rem 0;
}
/* Blockquotes */
.chat-page-bubble.assistant blockquote {
border-left: 3px solid var(--color-sb-accent);
margin: 0.625rem 0;
padding: 0.5rem 0.75rem;
background: rgba(59, 130, 246, 0.08);
border-radius: 0 0.375rem 0.375rem 0;
color: var(--color-sb-text);
}
.chat-page-bubble.assistant blockquote p {
margin: 0.25rem 0;
}
/* Tables */
.chat-page-bubble.assistant table {
width: 100%;
border-collapse: collapse;
margin: 0.625rem 0;
font-size: 0.8125rem;
display: block;
overflow-x: auto;
}
.chat-page-bubble.assistant th,
.chat-page-bubble.assistant td {
padding: 0.375rem 0.625rem;
border: 1px solid var(--color-sb-border);
text-align: left;
}
.chat-page-bubble.assistant th {
background: var(--color-sb-surface);
color: var(--color-sb-text-bright);
font-weight: 600;
}
.chat-page-bubble.assistant tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
/* Links */
.chat-page-bubble.assistant a {
color: var(--color-sb-accent);
text-decoration: none;
}
.chat-page-bubble.assistant a:hover {
text-decoration: underline;
}
/* KaTeX */
.chat-page-bubble.assistant .katex {
font-size: 1em;
color: var(--color-sb-text-bright);
}
.chat-page-bubble.assistant .katex-display {
margin: 0.625rem 0;
overflow-x: auto;
overflow-y: hidden;
padding: 0.5rem 0;
}
.chat-page-bubble.assistant .katex-display > .katex {
white-space: normal;
}
/* ── Status + reasoning ──────────────────────────────── */
.chat-page-status {
text-align: center;
font-size: 0.75rem;
color: var(--color-sb-muted);
padding: 0.25rem 0;
font-style: italic;
}
.chat-page-reasoning {
font-size: 0.75rem;
color: var(--color-sb-muted);
margin-bottom: 0.25rem;
}
.chat-page-reasoning summary {
cursor: pointer;
color: var(--color-sb-muted);
font-size: 0.75rem;
}
.chat-page-reasoning summary:hover {
color: var(--color-sb-text);
}
.chat-page-reasoning-body {
margin-top: 0.25rem;
padding: 0.5rem 0.75rem;
background: var(--color-sb-surface);
border-radius: 0.5rem;
border: 1px solid var(--color-sb-border);
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* ── Streaming cursor ────────────────────────────────── */
.chat-page-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--color-sb-accent);
animation: chat-page-blink 1s step-end infinite;
vertical-align: text-bottom;
margin-left: 1px;
}
@keyframes chat-page-blink {
50% { opacity: 0; }
}
/* ── Input area ──────────────────────────────────────── */
.chat-page-input-container {
flex-shrink: 0;
padding: 0.75rem 1.5rem 1rem;
border-top: 1px solid var(--color-sb-border);
background: var(--color-sb-surface);
}
.chat-page-input-context {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.6875rem;
color: var(--color-sb-accent);
background: rgba(37, 99, 235, 0.1);
border-radius: 0.25rem;
width: fit-content;
}
.chat-page-input-row {
display: flex;
align-items: flex-end;
gap: 0.5rem;
}
.chat-page-textarea {
flex: 1;
background: var(--color-sb-bg);
border: 1px solid var(--color-sb-border);
border-radius: 0.5rem;
padding: 0.625rem 0.875rem;
font-size: 0.9375rem;
color: var(--color-sb-text);
outline: none;
font-family: var(--font-sans);
transition: border-color 0.15s;
resize: none;
line-height: 1.5;
min-height: 2.5rem;
max-height: 200px;
}
.chat-page-textarea::placeholder {
color: var(--color-sb-muted);
}
.chat-page-textarea:focus {
border-color: var(--color-sb-accent);
}
.chat-page-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
border: none;
background: var(--color-sb-accent);
color: white;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
}
.chat-page-send-btn:hover:not(:disabled) {
background: var(--color-sb-accent-hover);
}
.chat-page-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.chat-page-send-btn.abort {
background: var(--color-sb-danger);
}
.chat-page-send-btn.abort:hover {
background: #ef4444;
}
.chat-page-input-hint {
margin-top: 0.375rem;
font-size: 0.6875rem;
color: var(--color-sb-muted);
}
/* ── Responsive ──────────────────────────────────────── */
@media (max-width: 767px) {
.chat-page-messages {
padding: 1rem;
}
.chat-page-bubble {
max-width: 100%;
}
.chat-page-input-container {
padding: 0.625rem 1rem 0.75rem;
}
.chat-page-input-hint {
display: none;
}
}
/* Wider screens — center messages with max width */
@media (min-width: 1200px) {
.chat-page-messages {
padding-left: calc((100% - 800px) / 2);
padding-right: calc((100% - 800px) / 2);
}
}

View File

@ -1,514 +0,0 @@
/* SpiceBook Chat Widget — dark-only, floating panel */
.chat-fab {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 1000;
width: 3rem;
height: 3rem;
border-radius: 50%;
border: 1px solid var(--color-sb-border);
background: var(--color-sb-surface);
color: var(--color-sb-accent);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, border-color 0.15s, transform 0.15s;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.chat-fab:hover {
background: var(--color-sb-cell);
border-color: var(--color-sb-accent);
transform: scale(1.05);
}
.chat-fab.active {
background: var(--color-sb-accent);
color: var(--color-sb-text-bright);
border-color: var(--color-sb-accent);
}
/* Panel */
.chat-panel {
position: fixed;
bottom: 5rem;
right: 1.5rem;
z-index: 1000;
width: 24rem;
max-height: 70vh;
display: flex;
flex-direction: column;
background: var(--color-sb-bg);
border: 1px solid var(--color-sb-border);
border-radius: 0.75rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
overflow: hidden;
animation: chat-slide-up 0.2s ease-out;
}
@keyframes chat-slide-up {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Header */
.chat-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-sb-border);
background: var(--color-sb-surface);
flex-shrink: 0;
}
.chat-header-title {
flex: 1;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-sb-text-bright);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: var(--color-sb-muted);
cursor: pointer;
transition: background 0.1s, color 0.1s;
flex-shrink: 0;
}
.chat-header-btn:hover {
background: var(--color-sb-cell);
color: var(--color-sb-text);
}
/* Messages */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
min-height: 200px;
}
.chat-messages::-webkit-scrollbar {
width: 4px;
}
.chat-messages::-webkit-scrollbar-thumb {
background: var(--color-sb-border);
border-radius: 2px;
}
.chat-bubble {
max-width: 88%;
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
font-size: 0.8125rem;
line-height: 1.5;
word-wrap: break-word;
overflow-wrap: break-word;
}
.chat-bubble.user {
align-self: flex-end;
background: var(--color-sb-accent);
color: white;
border-bottom-right-radius: 0.25rem;
}
.chat-bubble.assistant {
align-self: flex-start;
background: var(--color-sb-cell);
color: var(--color-sb-text);
border-bottom-left-radius: 0.25rem;
}
.chat-bubble.assistant strong {
color: var(--color-sb-text-bright);
}
.chat-bubble.assistant code {
font-family: var(--font-mono);
font-size: 0.75rem;
background: var(--color-sb-code-bg);
padding: 0.0625rem 0.25rem;
border-radius: 3px;
color: #93c5fd;
}
.chat-bubble.assistant pre {
background: var(--color-sb-code-bg);
border: 1px solid var(--color-sb-border);
border-radius: 0.375rem;
padding: 0.5rem;
margin: 0.375rem 0;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 0.75rem;
}
.chat-bubble.assistant pre code {
background: transparent;
padding: 0;
}
/* Markdown headings */
.chat-bubble.assistant h1,
.chat-bubble.assistant h2,
.chat-bubble.assistant h3,
.chat-bubble.assistant h4 {
color: var(--color-sb-text-bright);
font-weight: 600;
margin: 0.75rem 0 0.25rem;
line-height: 1.3;
}
.chat-bubble.assistant h1 { font-size: 1rem; }
.chat-bubble.assistant h2 { font-size: 0.9375rem; }
.chat-bubble.assistant h3 { font-size: 0.875rem; }
.chat-bubble.assistant h4 { font-size: 0.8125rem; }
.chat-bubble.assistant h1:first-child,
.chat-bubble.assistant h2:first-child,
.chat-bubble.assistant h3:first-child,
.chat-bubble.assistant h4:first-child {
margin-top: 0;
}
/* Paragraphs */
.chat-bubble.assistant p {
margin: 0.375rem 0;
}
.chat-bubble.assistant p:first-child {
margin-top: 0;
}
.chat-bubble.assistant p:last-child {
margin-bottom: 0;
}
/* Lists */
.chat-bubble.assistant ul,
.chat-bubble.assistant ol {
margin: 0.375rem 0;
padding-left: 1.25rem;
}
.chat-bubble.assistant li {
margin: 0.125rem 0;
}
.chat-bubble.assistant li > ul,
.chat-bubble.assistant li > ol {
margin: 0.125rem 0;
}
/* Horizontal rules */
.chat-bubble.assistant hr {
border: none;
border-top: 1px solid var(--color-sb-border);
margin: 0.625rem 0;
}
/* Blockquotes */
.chat-bubble.assistant blockquote {
border-left: 3px solid var(--color-sb-accent);
margin: 0.5rem 0;
padding: 0.375rem 0.625rem;
background: rgba(59, 130, 246, 0.08);
border-radius: 0 0.25rem 0.25rem 0;
color: var(--color-sb-text);
}
.chat-bubble.assistant blockquote p {
margin: 0.25rem 0;
}
/* Tables — wrapped in a scrollable container via overflow on the bubble */
.chat-bubble.assistant table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
font-size: 0.75rem;
display: block;
overflow-x: auto;
}
.chat-bubble.assistant th,
.chat-bubble.assistant td {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-sb-border);
text-align: left;
}
.chat-bubble.assistant th {
background: var(--color-sb-surface);
color: var(--color-sb-text-bright);
font-weight: 600;
}
.chat-bubble.assistant tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
/* Links */
.chat-bubble.assistant a {
color: var(--color-sb-accent);
text-decoration: none;
}
.chat-bubble.assistant a:hover {
text-decoration: underline;
}
/* KaTeX math — dark theme overrides */
.chat-bubble.assistant .katex {
font-size: 0.9em;
color: var(--color-sb-text-bright);
}
.chat-bubble.assistant .katex-display {
margin: 0.5rem 0;
overflow-x: auto;
overflow-y: hidden;
padding: 0.375rem 0;
}
.chat-bubble.assistant .katex-display > .katex {
white-space: normal;
}
/* Status messages */
.chat-status {
text-align: center;
font-size: 0.6875rem;
color: var(--color-sb-muted);
padding: 0.25rem 0;
font-style: italic;
}
/* Reasoning (collapsed) */
.chat-reasoning {
font-size: 0.6875rem;
color: var(--color-sb-muted);
margin-bottom: 0.25rem;
}
.chat-reasoning summary {
cursor: pointer;
color: var(--color-sb-muted);
font-size: 0.6875rem;
}
.chat-reasoning summary:hover {
color: var(--color-sb-text);
}
.chat-reasoning-body {
margin-top: 0.25rem;
padding: 0.375rem 0.5rem;
background: var(--color-sb-surface);
border-radius: 0.375rem;
border: 1px solid var(--color-sb-border);
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* Streaming cursor */
.chat-cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--color-sb-accent);
animation: chat-blink 1s step-end infinite;
vertical-align: text-bottom;
margin-left: 1px;
}
@keyframes chat-blink {
50% { opacity: 0; }
}
/* Input */
.chat-input-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-top: 1px solid var(--color-sb-border);
background: var(--color-sb-surface);
flex-shrink: 0;
}
.chat-input {
flex: 1;
background: var(--color-sb-bg);
border: 1px solid var(--color-sb-border);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
color: var(--color-sb-text);
outline: none;
font-family: var(--font-sans);
transition: border-color 0.15s;
}
.chat-input::placeholder {
color: var(--color-sb-muted);
}
.chat-input:focus {
border-color: var(--color-sb-accent);
}
.chat-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
border: none;
background: var(--color-sb-accent);
color: white;
cursor: pointer;
transition: background 0.15s;
flex-shrink: 0;
}
.chat-send-btn:hover:not(:disabled) {
background: var(--color-sb-accent-hover);
}
.chat-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* History view */
.chat-history-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
min-height: 200px;
}
.chat-history-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.625rem;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.1s;
border: none;
background: transparent;
color: var(--color-sb-text);
text-align: left;
font-size: 0.8125rem;
width: 100%;
}
.chat-history-item:hover {
background: var(--color-sb-cell);
}
.chat-history-item.active {
background: var(--color-sb-cell);
border: 1px solid var(--color-sb-border);
}
.chat-history-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-history-count {
font-size: 0.6875rem;
color: var(--color-sb-muted);
flex-shrink: 0;
}
.chat-history-delete {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
border: none;
background: transparent;
color: var(--color-sb-muted);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
}
.chat-history-item:hover .chat-history-delete {
opacity: 1;
}
.chat-history-delete:hover {
color: var(--color-sb-danger);
}
.chat-history-delete.confirm {
color: var(--color-sb-danger);
opacity: 1;
}
.chat-empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--color-sb-muted);
font-size: 0.8125rem;
font-style: italic;
}
/* Responsive: smaller screens */
@media (max-width: 480px) {
.chat-panel {
width: calc(100vw - 2rem);
right: 1rem;
bottom: 4.5rem;
max-height: 60vh;
}
.chat-fab {
bottom: 1rem;
right: 1rem;
}
}

View File

@ -32,33 +32,6 @@
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
}
/* shadcn-ui CSS variable mappings mapped to SpiceBook's dark slate theme.
These are consumed by Radix primitives and shadcn components.
We intentionally avoid re-declaring a full light/dark toggle since SpiceBook
is dark-only we just wire the shadcn variable names to our tokens. */
:root {
--background: var(--color-sb-bg);
--foreground: var(--color-sb-text);
--card: var(--color-sb-surface);
--card-foreground: var(--color-sb-text);
--popover: var(--color-sb-surface);
--popover-foreground: var(--color-sb-text);
--primary: var(--color-sb-accent);
--primary-foreground: #ffffff;
--secondary: var(--color-sb-cell);
--secondary-foreground: var(--color-sb-text);
--muted: var(--color-sb-cell);
--muted-foreground: var(--color-sb-muted);
--accent: var(--color-sb-cell);
--accent-foreground: var(--color-sb-text-bright);
--destructive: var(--color-sb-danger);
--destructive-foreground: #ffffff;
--border: var(--color-sb-border);
--input: var(--color-sb-border);
--ring: var(--color-sb-accent);
--radius: 0.5rem;
}
/* Waveform canvas colors (read by JS via getComputedStyle) */
:root {
--color-wf-axis: #475569;