Compare commits
7 Commits
09fd59d570
...
c0639c775c
| Author | SHA1 | Date | |
|---|---|---|---|
| c0639c775c | |||
| 70efde8aa6 | |||
| 5db321c8e3 | |||
| 78350e02af | |||
| b89e520479 | |||
| 6f239c185e | |||
| 99d1ca28d2 |
13
CLAUDE.md
13
CLAUDE.md
@ -12,14 +12,23 @@ Notebook interface for SPICE circuit simulation.
|
||||
```bash
|
||||
# Local dev (Docker)
|
||||
make dev
|
||||
# Frontend: http://localhost:4321
|
||||
# Backend: http://localhost:8099
|
||||
# Local site: https://spicebook.l.warehack.ing (via caddy-docker-proxy)
|
||||
# API docs: https://spicebook.l.warehack.ing/docs
|
||||
|
||||
# Local dev (no Docker)
|
||||
cd backend && uv run uvicorn spicebook.main:app --host 0.0.0.0 --port 8099 --reload
|
||||
cd frontend && npm run dev -- --host 0.0.0.0 --port 4322
|
||||
```
|
||||
|
||||
### Local vs Production domains
|
||||
|
||||
| Environment | Domain | `.env` setting |
|
||||
|-------------|--------|----------------|
|
||||
| Local dev | `spicebook.l.warehack.ing` | `SPICEBOOK_DOMAIN=spicebook.l.warehack.ing` |
|
||||
| Production | `spicebook.warehack.ing` | `SPICEBOOK_DOMAIN=spicebook.warehack.ing` |
|
||||
|
||||
The `.env` on the dev machine should use `spicebook.l.warehack.ing` — the `.l.` subdomain resolves locally. Using the production domain (`spicebook.warehack.ing`) will route requests to the remote production server, not the local containers.
|
||||
|
||||
## Deployment to Production
|
||||
|
||||
Production runs on `warehack.ing` (149.28.126.25) behind caddy-docker-proxy.
|
||||
|
||||
@ -12,6 +12,8 @@ dependencies = [
|
||||
"numpy>=1.24.0",
|
||||
"websockets>=12.0",
|
||||
"schemdraw>=0.19",
|
||||
"fastmcp>=3.0.0",
|
||||
"httpx>=0.28.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
0
backend/src/spicebook/chat/__init__.py
Normal file
0
backend/src/spicebook/chat/__init__.py
Normal file
149
backend/src/spicebook/chat/llm.py
Normal file
149
backend/src/spicebook/chat/llm.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""LLM client for chat completions via the GPU gateway."""
|
||||
|
||||
import asyncio
|
||||
import collections.abc
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from spicebook.config import settings
|
||||
|
||||
logger = logging.getLogger("spicebook.chat")
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"/no_think\n"
|
||||
"You are a circuit simulation assistant integrated into SpiceBook, "
|
||||
"a notebook environment for SPICE circuit design and simulation. "
|
||||
"Help users understand circuits, debug netlists, interpret simulation "
|
||||
"results, and design new circuits. When the user is viewing a notebook, "
|
||||
"you will receive its SPICE cells and notes as context. "
|
||||
"Be precise and practical — reference specific components, nodes, "
|
||||
"and values from their circuit when available. "
|
||||
"Format responses with markdown for readability."
|
||||
)
|
||||
|
||||
# Module-level client reuses TCP/TLS connections across requests.
|
||||
# Lock prevents race condition on lazy initialization.
|
||||
_chat_client: httpx.AsyncClient | None = None
|
||||
_client_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _get_chat_client() -> httpx.AsyncClient:
|
||||
global _chat_client
|
||||
if _chat_client is not None and not _chat_client.is_closed:
|
||||
return _chat_client
|
||||
async with _client_lock:
|
||||
# Double-check after acquiring lock
|
||||
if _chat_client is not None and not _chat_client.is_closed:
|
||||
return _chat_client
|
||||
_chat_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(
|
||||
connect=10.0,
|
||||
read=120.0,
|
||||
write=10.0,
|
||||
pool=10.0,
|
||||
),
|
||||
limits=httpx.Limits(
|
||||
max_connections=20,
|
||||
max_keepalive_connections=10,
|
||||
),
|
||||
)
|
||||
return _chat_client
|
||||
|
||||
|
||||
async def close_client() -> None:
|
||||
"""Close the HTTP client. Called during app shutdown."""
|
||||
global _chat_client
|
||||
async with _client_lock:
|
||||
if _chat_client is not None and not _chat_client.is_closed:
|
||||
await _chat_client.aclose()
|
||||
_chat_client = None
|
||||
|
||||
|
||||
async def chat_completion_stream(
|
||||
context: str, question: str
|
||||
) -> collections.abc.AsyncIterator[tuple[str, str]]:
|
||||
"""Stream chat completion tokens from the GPU gateway.
|
||||
|
||||
Yields (kind, text) tuples where kind is 'content' or 'reasoning'.
|
||||
"""
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
]
|
||||
if context:
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": f"Current notebook context:\n\n{context}\n\n---\n\nQuestion: {question}",
|
||||
})
|
||||
else:
|
||||
messages.append({"role": "user", "content": question})
|
||||
|
||||
client = await _get_chat_client()
|
||||
logger.debug(
|
||||
"Chat stream: model=%s max_tokens=%d context_len=%d",
|
||||
settings.gpu_chat_model, settings.chat_max_tokens, len(context),
|
||||
)
|
||||
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{settings.gpu_base_url}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.gpu_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": settings.gpu_chat_model,
|
||||
"messages": messages,
|
||||
"temperature": 0.3,
|
||||
"max_tokens": settings.chat_max_tokens,
|
||||
"num_ctx": settings.chat_max_tokens,
|
||||
"stream": True,
|
||||
},
|
||||
) as resp:
|
||||
if resp.status_code >= 400:
|
||||
body = await resp.aread()
|
||||
error_text = body[:500].decode("utf-8", errors="replace")
|
||||
logger.error("LLM gateway returned %d: %s", resp.status_code, error_text)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"LLM gateway error {resp.status_code}",
|
||||
request=resp.request,
|
||||
response=resp,
|
||||
)
|
||||
reasoning_count = 0
|
||||
content_count = 0
|
||||
try:
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.startswith("data: "):
|
||||
continue
|
||||
payload = line[6:]
|
||||
if payload.strip() == "[DONE]":
|
||||
logger.debug(
|
||||
"Chat stream done: %d reasoning chunks, %d content chunks",
|
||||
reasoning_count, content_count,
|
||||
)
|
||||
return
|
||||
try:
|
||||
chunk = json.loads(payload)
|
||||
delta = chunk["choices"][0]["delta"]
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
content_count += 1
|
||||
yield ("content", content)
|
||||
elif delta.get("reasoning_content"):
|
||||
reasoning_count += 1
|
||||
yield ("reasoning", delta["reasoning_content"])
|
||||
finish = chunk["choices"][0].get("finish_reason")
|
||||
if finish == "length":
|
||||
logger.warning(
|
||||
"Chat stream truncated (finish_reason=length) "
|
||||
"after %d reasoning + %d content chunks",
|
||||
reasoning_count, content_count,
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError, IndexError):
|
||||
logger.debug("Unparseable SSE chunk: %s", payload[:200])
|
||||
continue
|
||||
except GeneratorExit:
|
||||
logger.debug("Chat stream cancelled after %d content chunks", content_count)
|
||||
await resp.aclose()
|
||||
return
|
||||
@ -16,6 +16,14 @@ class Settings:
|
||||
self.backend_host: str = os.environ.get("BACKEND_HOST", "0.0.0.0")
|
||||
self.backend_port: int = int(os.environ.get("BACKEND_PORT", "8000"))
|
||||
|
||||
# GPU LLM gateway (chat)
|
||||
self.gpu_api_key: str = os.environ.get("GPU_API_KEY", "")
|
||||
self.gpu_base_url: str = os.environ.get(
|
||||
"GPU_BASE_URL", "https://spicebook.gpu.supported.systems/v1"
|
||||
)
|
||||
self.gpu_chat_model: str = os.environ.get("GPU_CHAT_MODEL", "qwen3")
|
||||
self.chat_max_tokens: int = int(os.environ.get("CHAT_MAX_TOKENS", "8192"))
|
||||
|
||||
@property
|
||||
def examples_dir(self) -> Path:
|
||||
return self.notebook_dir / "examples"
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
"""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 HTTPException(status_code=400, detail=f"Unsupported engine: '{engine_name}'")
|
||||
raise UnsupportedEngineError(f"Unsupported engine: '{engine_name}'")
|
||||
|
||||
@ -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")
|
||||
.label(_component_label(source), loc="left", ofst=0.15)
|
||||
)
|
||||
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")
|
||||
.label(_component_label(source), loc="left", ofst=0.15)
|
||||
)
|
||||
|
||||
# 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))
|
||||
.label(_component_label(comp), ofst=0.15)
|
||||
)
|
||||
|
||||
# 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")
|
||||
.label(_component_label(last_comp), loc="right", ofst=0.15)
|
||||
)
|
||||
|
||||
# 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))
|
||||
elem.at((x, center_y)).label(_component_label(comp), loc="right", ofst=0.15)
|
||||
)
|
||||
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)))
|
||||
d.add(getattr(elem, h_dir)().label(_component_label(comp), ofst=0.15))
|
||||
|
||||
# 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"))
|
||||
q = d.add(dev_elem.label(_component_label(layout.device), loc="right", ofst=0.15))
|
||||
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"))
|
||||
q = d.add(dev_elem.label(_component_label(layout.device), loc="right", ofst=0.15))
|
||||
anchors = {
|
||||
"drain": q.drain,
|
||||
"gate": q.gate,
|
||||
|
||||
@ -4,22 +4,39 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastmcp.utilities.lifespan import combine_lifespans
|
||||
|
||||
from spicebook.chat.llm import close_client
|
||||
from spicebook.config import settings
|
||||
from spicebook.routers import compose, notebooks, schematics, simulation, waveforms
|
||||
from spicebook.mcp import mcp
|
||||
from spicebook.routers import chat, compose, notebooks, schematics, simulation, waveforms
|
||||
|
||||
logger = logging.getLogger("spicebook")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
_configure_logging()
|
||||
_validate_ngspice()
|
||||
_ensure_directories()
|
||||
yield
|
||||
await close_client()
|
||||
|
||||
|
||||
mcp_app = mcp.http_app(path="/", stateless_http=True)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
application = FastAPI(
|
||||
title="SpiceBook",
|
||||
description="Notebook interface for SPICE circuit simulation",
|
||||
version="2026.02.13",
|
||||
lifespan=combine_lifespans(lifespan, mcp_app.lifespan),
|
||||
)
|
||||
|
||||
# CORS -- allow dev frontends + configurable extra origins
|
||||
@ -66,16 +83,14 @@ def create_app() -> FastAPI:
|
||||
application.include_router(schematics.router)
|
||||
application.include_router(simulation.router)
|
||||
application.include_router(waveforms.router)
|
||||
application.include_router(chat.router)
|
||||
|
||||
@application.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": "2026.02.13"}
|
||||
|
||||
@application.on_event("startup")
|
||||
async def startup():
|
||||
_configure_logging()
|
||||
_validate_ngspice()
|
||||
_ensure_directories()
|
||||
# Mount MCP server at /mcp
|
||||
application.mount("/mcp", mcp_app)
|
||||
|
||||
return application
|
||||
|
||||
|
||||
15
backend/src/spicebook/mcp/__init__.py
Normal file
15
backend/src/spicebook/mcp/__init__.py
Normal file
@ -0,0 +1,15 @@
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP(
|
||||
"SpiceBook",
|
||||
instructions=(
|
||||
"MCP server for SpiceBook circuit simulation notebooks. "
|
||||
"Create, manage, and simulate SPICE circuit notebooks. "
|
||||
"Use list_notebooks to browse, get_notebook to fetch details, "
|
||||
"simulate_netlist for standalone simulations, and run_cell "
|
||||
"to execute notebook cells."
|
||||
),
|
||||
)
|
||||
|
||||
# Register tools, resources, and prompts
|
||||
import spicebook.mcp.tools # noqa: E402, F401
|
||||
197
backend/src/spicebook/mcp/tools.py
Normal file
197
backend/src/spicebook/mcp/tools.py
Normal file
@ -0,0 +1,197 @@
|
||||
"""MCP tools wrapping SpiceBook storage and engine functions."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from spicebook.config import settings
|
||||
from spicebook.engine import get_engine
|
||||
from spicebook.mcp import mcp
|
||||
from spicebook.storage.filesystem import (
|
||||
create_notebook,
|
||||
delete_notebook,
|
||||
list_notebooks,
|
||||
load_notebook,
|
||||
save_notebook,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("spicebook.mcp")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_all_notebooks() -> str:
|
||||
"""List all SpiceBook notebooks with metadata.
|
||||
|
||||
Returns a JSON array of notebook summaries including id, title, engine,
|
||||
tags, cell count, and modification date.
|
||||
"""
|
||||
summaries = list_notebooks(settings.notebook_dir)
|
||||
return json.dumps(
|
||||
[s.model_dump() for s in summaries],
|
||||
indent=2,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_notebook(notebook_id: str) -> str:
|
||||
"""Fetch a complete SpiceBook notebook by its ID.
|
||||
|
||||
Returns the full notebook JSON including all cells, metadata, and outputs.
|
||||
|
||||
Args:
|
||||
notebook_id: The unique notebook identifier (slug-based, e.g. 'rc-filter-a1b2c3d4')
|
||||
"""
|
||||
nb = load_notebook(settings.notebook_dir, notebook_id)
|
||||
if nb is None:
|
||||
return json.dumps({"error": f"Notebook '{notebook_id}' not found"})
|
||||
return nb.model_dump_json(indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_new_notebook(title: str, engine: str = "ngspice") -> str:
|
||||
"""Create a new empty SpiceBook notebook.
|
||||
|
||||
Args:
|
||||
title: Human-readable title for the notebook (e.g. 'Low-Pass RC Filter')
|
||||
engine: Simulation engine to use ('ngspice' or 'ltspice')
|
||||
"""
|
||||
nb_id, nb = create_notebook(settings.notebook_dir, title, engine)
|
||||
return json.dumps({"id": nb_id, "notebook": json.loads(nb.model_dump_json())})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def delete_user_notebook(notebook_id: str) -> str:
|
||||
"""Delete a user-created notebook. Example notebooks cannot be deleted.
|
||||
|
||||
Args:
|
||||
notebook_id: The notebook ID to delete
|
||||
"""
|
||||
deleted = delete_notebook(settings.notebook_dir, notebook_id)
|
||||
if deleted:
|
||||
return json.dumps({"deleted": True, "notebook_id": notebook_id})
|
||||
return json.dumps({
|
||||
"deleted": False,
|
||||
"error": f"Notebook '{notebook_id}' not found or is an example",
|
||||
})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def simulate_netlist(netlist: str, engine: str = "ngspice") -> str:
|
||||
"""Run a standalone SPICE simulation on a netlist string.
|
||||
|
||||
Args:
|
||||
netlist: Complete SPICE netlist text (must include .end directive)
|
||||
engine: Simulation engine to use ('ngspice')
|
||||
"""
|
||||
eng = get_engine(engine)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result = await eng.run(netlist, Path(tmpdir))
|
||||
return result.model_dump_json(indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def run_cell(notebook_id: str, cell_id: str) -> str:
|
||||
"""Run a SPICE cell within a notebook and save results.
|
||||
|
||||
Args:
|
||||
notebook_id: The notebook containing the cell
|
||||
cell_id: The ID of the SPICE cell to execute
|
||||
"""
|
||||
nb = load_notebook(settings.notebook_dir, notebook_id)
|
||||
if nb is None:
|
||||
return json.dumps({"error": f"Notebook '{notebook_id}' not found"})
|
||||
|
||||
cell = next((c for c in nb.cells if c.id == cell_id), None)
|
||||
if cell is None:
|
||||
return json.dumps({"error": f"Cell '{cell_id}' not found in notebook"})
|
||||
if cell.type.value != "spice":
|
||||
return json.dumps({"error": f"Cell '{cell_id}' is type '{cell.type.value}', not 'spice'"})
|
||||
|
||||
eng = get_engine(nb.metadata.engine)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result = await eng.run(cell.source, Path(tmpdir))
|
||||
|
||||
# Update cell outputs (preserve schematics)
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from spicebook.models.notebook import CellOutput
|
||||
|
||||
sim_output = CellOutput(
|
||||
output_type="simulation_result" if result.success else "error",
|
||||
data={
|
||||
"success": result.success,
|
||||
"waveform": result.waveform.model_dump() if result.waveform else None,
|
||||
"log": result.log,
|
||||
"error": result.error,
|
||||
"elapsed_seconds": result.elapsed_seconds,
|
||||
},
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
preserved = [o for o in cell.outputs if o.output_type == "schematic"]
|
||||
cell.outputs = [sim_output, *preserved]
|
||||
save_notebook(settings.notebook_dir, notebook_id, nb)
|
||||
|
||||
return result.model_dump_json(indent=2)
|
||||
|
||||
|
||||
# ── Resource ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.resource("spicebook://status")
|
||||
def spicebook_status() -> str:
|
||||
"""SpiceBook server status: notebook count, available engines, and storage path."""
|
||||
summaries = list_notebooks(settings.notebook_dir)
|
||||
engines = ["ngspice"]
|
||||
if settings.ltspice_dir:
|
||||
engines.append("ltspice")
|
||||
return json.dumps({
|
||||
"notebook_count": len(summaries),
|
||||
"engines": engines,
|
||||
"notebook_dir": str(settings.notebook_dir.resolve()),
|
||||
})
|
||||
|
||||
|
||||
# ── Prompt ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def circuit_assistant(topic: str = "general") -> str:
|
||||
"""Guided circuit design workflow prompt for SpiceBook.
|
||||
|
||||
Args:
|
||||
topic: Focus area — 'general', 'filter', 'amplifier', 'power', 'digital'
|
||||
"""
|
||||
topics = {
|
||||
"general": (
|
||||
"You are a circuit design assistant working with SpiceBook notebooks. "
|
||||
"Help the user design, simulate, and analyze circuits. Start by asking "
|
||||
"what kind of circuit they want to build, then guide them through "
|
||||
"component selection, netlist creation, and simulation analysis."
|
||||
),
|
||||
"filter": (
|
||||
"You are a filter design specialist. Help the user design passive or "
|
||||
"active filters (low-pass, high-pass, band-pass, notch). Guide them "
|
||||
"through cutoff frequency selection, component calculations, and "
|
||||
"AC analysis interpretation using SpiceBook."
|
||||
),
|
||||
"amplifier": (
|
||||
"You are an amplifier design specialist. Help the user design "
|
||||
"transistor or op-amp amplifier circuits. Guide them through "
|
||||
"biasing, gain calculations, frequency response, and transient "
|
||||
"analysis using SpiceBook."
|
||||
),
|
||||
"power": (
|
||||
"You are a power supply design specialist. Help the user design "
|
||||
"voltage regulators, converters, and power distribution circuits. "
|
||||
"Guide them through component selection, efficiency analysis, and "
|
||||
"transient response using SpiceBook."
|
||||
),
|
||||
"digital": (
|
||||
"You are a digital circuit design specialist. Help the user design "
|
||||
"logic gates, flip-flops, and timing circuits using SPICE models. "
|
||||
"Guide them through timing analysis and signal integrity using SpiceBook."
|
||||
),
|
||||
}
|
||||
return topics.get(topic, topics["general"])
|
||||
18
backend/src/spicebook/models/chat.py
Normal file
18
backend/src/spicebook/models/chat.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Pydantic models for the chat streaming endpoint."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NotebookContext(BaseModel):
|
||||
"""Context about the notebook the user is currently viewing."""
|
||||
|
||||
notebook_id: str = Field("", max_length=200)
|
||||
title: str = Field("", max_length=200)
|
||||
engine: str = Field("ngspice", max_length=50)
|
||||
|
||||
|
||||
class ChatStreamRequest(BaseModel):
|
||||
"""Request body for the SSE chat streaming endpoint."""
|
||||
|
||||
question: str = Field(..., min_length=1, max_length=2000)
|
||||
notebook: NotebookContext | None = None
|
||||
109
backend/src/spicebook/routers/chat.py
Normal file
109
backend/src/spicebook/routers/chat.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""SSE streaming chat endpoint for the SpiceBook chat widget."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from spicebook.chat.llm import chat_completion_stream
|
||||
from spicebook.config import settings
|
||||
from spicebook.models.chat import ChatStreamRequest
|
||||
from spicebook.storage.filesystem import load_notebook
|
||||
|
||||
logger = logging.getLogger("spicebook.chat")
|
||||
|
||||
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
||||
|
||||
|
||||
def _sse_event(event: str, data: dict | list) -> str:
|
||||
payload = json.dumps(data, separators=(",", ":"))
|
||||
return f"event: {event}\ndata: {payload}\n\n"
|
||||
|
||||
|
||||
async def _build_notebook_context(req: ChatStreamRequest) -> str:
|
||||
"""Extract SPICE cells and markdown notes from the notebook for LLM context."""
|
||||
if not req.notebook or not req.notebook.notebook_id:
|
||||
return ""
|
||||
|
||||
nb = await asyncio.to_thread(load_notebook, settings.notebook_dir, req.notebook.notebook_id)
|
||||
if nb is None:
|
||||
return ""
|
||||
|
||||
parts = [f'Notebook: "{nb.metadata.title}" (engine: {nb.metadata.engine})']
|
||||
|
||||
for i, cell in enumerate(nb.cells):
|
||||
if cell.type.value == "spice" and cell.source.strip():
|
||||
parts.append(f"\n--- SPICE Cell {i + 1} ---\n{cell.source.strip()}")
|
||||
# Include latest simulation result summary if available
|
||||
for output in cell.outputs:
|
||||
if output.output_type in ("simulation_result", "error"):
|
||||
success = output.data.get("success", False)
|
||||
if not success and output.data.get("error"):
|
||||
parts.append(f" [Simulation error: {output.data['error']}]")
|
||||
elif success and output.data.get("waveform"):
|
||||
wf = output.data["waveform"]
|
||||
var_names = [v.get("name", "") for v in wf.get("variables", [])]
|
||||
sig_list = ", ".join(var_names)
|
||||
pts = wf.get("points", 0)
|
||||
parts.append(f" [Simulation OK: {pts} points, signals: {sig_list}]")
|
||||
break
|
||||
elif cell.type.value == "markdown" and cell.source.strip():
|
||||
# Include markdown notes (truncated)
|
||||
text = cell.source.strip()[:500]
|
||||
parts.append(f"\n--- Notes ---\n{text}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@router.post("/stream")
|
||||
async def chat_stream(req: ChatStreamRequest):
|
||||
"""SSE streaming endpoint for the chat widget."""
|
||||
|
||||
async def generate():
|
||||
# Build context from the notebook if available
|
||||
context = await _build_notebook_context(req)
|
||||
|
||||
question = req.question
|
||||
if req.notebook and req.notebook.title:
|
||||
nb_title = req.notebook.title
|
||||
nb_engine = req.notebook.engine
|
||||
question = (
|
||||
f'[User is viewing notebook: "{nb_title}" ({nb_engine})]\n\n'
|
||||
+ req.question
|
||||
)
|
||||
|
||||
yield _sse_event("status", {"text": "Analyzing circuit context\u2026"})
|
||||
|
||||
try:
|
||||
async for kind, text in chat_completion_stream(context, question):
|
||||
yield _sse_event(
|
||||
"reasoning" if kind == "reasoning" else "token",
|
||||
{"text": text},
|
||||
)
|
||||
except (
|
||||
httpx.HTTPStatusError,
|
||||
httpx.ConnectError,
|
||||
httpx.ReadTimeout,
|
||||
httpx.PoolTimeout,
|
||||
httpx.ConnectTimeout,
|
||||
) as exc:
|
||||
logger.warning("Chat stream failed: %s", exc)
|
||||
yield _sse_event("error", {"text": "Chat service unavailable"})
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Chat stream cancelled by client disconnect")
|
||||
return
|
||||
|
||||
yield _sse_event("done", {})
|
||||
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
@ -9,7 +9,7 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from spicebook.config import settings
|
||||
from spicebook.engine import get_engine
|
||||
from spicebook.engine import UnsupportedEngineError, get_engine
|
||||
from spicebook.models.notebook import (
|
||||
Cell,
|
||||
CellOutput,
|
||||
@ -57,7 +57,10 @@ async def compose_notebook(req: ComposeNotebookRequest):
|
||||
)
|
||||
|
||||
if req.run:
|
||||
engine = get_engine(req.engine)
|
||||
try:
|
||||
engine = get_engine(req.engine)
|
||||
except UnsupportedEngineError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
for cell in notebook.cells:
|
||||
if cell.type != CellType.SPICE:
|
||||
continue
|
||||
|
||||
@ -7,7 +7,7 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from spicebook.config import settings
|
||||
from spicebook.engine import get_engine
|
||||
from spicebook.engine import UnsupportedEngineError, get_engine
|
||||
from spicebook.models.notebook import CellOutput, CellType
|
||||
from spicebook.models.simulation import SimulationRequest, SimulationResponse
|
||||
from spicebook.storage.filesystem import load_notebook, save_notebook
|
||||
@ -18,7 +18,10 @@ router = APIRouter(prefix="/api", tags=["simulation"])
|
||||
@router.post("/simulate", response_model=SimulationResponse)
|
||||
async def simulate(req: SimulationRequest):
|
||||
"""Run a standalone SPICE simulation."""
|
||||
engine = get_engine(req.engine)
|
||||
try:
|
||||
engine = get_engine(req.engine)
|
||||
except UnsupportedEngineError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
|
||||
result = await engine.run(req.netlist, Path(tmpdir))
|
||||
@ -54,7 +57,10 @@ async def run_cell(notebook_id: str, cell_id: str):
|
||||
if not cell.source.strip():
|
||||
raise HTTPException(status_code=400, detail="Cell source is empty")
|
||||
|
||||
engine = get_engine(nb.metadata.engine)
|
||||
try:
|
||||
engine = get_engine(nb.metadata.engine)
|
||||
except UnsupportedEngineError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="spicebook-sim-") as tmpdir:
|
||||
result = await engine.run(cell.source, Path(tmpdir))
|
||||
|
||||
@ -19,6 +19,18 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
SPICEBOOK_EXT = ".spicebook"
|
||||
|
||||
_SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,198}[a-z0-9]$")
|
||||
|
||||
|
||||
def validate_notebook_id(notebook_id: str) -> str:
|
||||
"""Validate notebook_id is a safe filesystem slug. Raises ValueError if not."""
|
||||
if not _SAFE_ID_RE.match(notebook_id):
|
||||
raise ValueError(
|
||||
f"Invalid notebook ID: {notebook_id!r}. "
|
||||
"Must be lowercase alphanumeric with hyphens, 2-200 chars."
|
||||
)
|
||||
return notebook_id
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
@ -123,6 +135,7 @@ def list_notebooks(directory: Path) -> list[NotebookSummary]:
|
||||
|
||||
def load_notebook(directory: Path, notebook_id: str) -> Notebook | None:
|
||||
"""Load a notebook by ID, searching user/ then examples/ subdirectories."""
|
||||
validate_notebook_id(notebook_id)
|
||||
for sub in ["user", "examples"]:
|
||||
path = directory / sub / f"{notebook_id}{SPICEBOOK_EXT}"
|
||||
if path.is_file():
|
||||
@ -132,6 +145,7 @@ 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)
|
||||
|
||||
@ -174,6 +188,7 @@ def delete_notebook(directory: Path, notebook_id: str) -> bool:
|
||||
|
||||
Returns True if deleted, False if not found.
|
||||
"""
|
||||
validate_notebook_id(notebook_id)
|
||||
path = directory / "user" / f"{notebook_id}{SPICEBOOK_EXT}"
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
|
||||
772
backend/uv.lock
generated
772
backend/uv.lock
generated
@ -2,6 +2,18 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "aiofile"
|
||||
version = "3.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "caio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
@ -33,6 +45,60 @@ 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"
|
||||
@ -42,6 +108,63 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
@ -63,6 +186,126 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cyclopts"
|
||||
version = "4.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "docstring-parser" },
|
||||
{ name = "rich" },
|
||||
{ name = "rich-rst" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/d2/f37df900b163f51b4faacdb01bf4895c198906d67c5b2a85c2522de85459/cyclopts-4.5.4.tar.gz", hash = "sha256:eed4d6c76d4391aa796d8fcaabd50e5aad7793261792beb19285f62c5c456c8b", size = 162438, upload-time = "2026-02-20T00:58:46.161Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/0f/119fa63fa93e0a331fbedcb27162d8f88d3ba2f38eba1567e3e44307b857/cyclopts-4.5.4-py3-none-any.whl", hash = "sha256:ad001986ec403ca1dc1ed20375c439d62ac796295ea32b451dfe25d6696bc71a", size = 200225, upload-time = "2026-02-20T00:58:47.275Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docstring-parser"
|
||||
version = "0.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docutils"
|
||||
version = "0.22.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email-validator"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.129.0"
|
||||
@ -79,6 +322,37 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/dd/d0ee25348ac58245ee9f90b6f3cbb666bf01f69be7e0911f9851bddbda16/fastapi-0.129.0-py3-none-any.whl", hash = "sha256:b4946880e48f462692b31c083be0432275cbfb6e2274566b1be91479cc1a84ec", size = 102950, upload-time = "2026-02-12T13:54:54.528Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastmcp"
|
||||
version = "3.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "authlib" },
|
||||
{ name = "cyclopts" },
|
||||
{ name = "exceptiongroup" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jsonref" },
|
||||
{ name = "jsonschema-path" },
|
||||
{ name = "mcp" },
|
||||
{ name = "openapi-pydantic" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "packaging" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "pyperclip" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/6b/1a7ec89727797fb07ec0928e9070fa2f45e7b35718e1fe01633a34c35e45/fastmcp-3.0.2.tar.gz", hash = "sha256:6bd73b4a3bab773ee6932df5249dcbcd78ed18365ed0aeeb97bb42702a7198d7", size = 17239351, upload-time = "2026-02-22T16:32:28.843Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/5a/f410a9015cfde71adf646dab4ef2feae49f92f34f6050fcfb265eb126b30/fastmcp-3.0.2-py3-none-any.whl", hash = "sha256:f513d80d4b30b54749fe8950116b1aab843f3c293f5cb971fc8665cb48dbb028", size = 606268, upload-time = "2026-02-22T16:32:30.992Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@ -145,6 +419,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@ -154,6 +437,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "8.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
@ -163,6 +458,170 @@ 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"
|
||||
@ -224,6 +683,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openapi-pydantic"
|
||||
version = "0.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.39.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "importlib-metadata" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
@ -233,6 +717,24 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathable"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/55/b748445cb4ea6b125626f15379be7c96d1035d4fa3e8fee362fa92298abf/pathable-0.5.0.tar.gz", hash = "sha256:d81938348a1cacb525e7c75166270644782c0fb9c8cecc16be033e71427e0ef1", size = 16655, upload-time = "2026-02-20T08:47:00.748Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/96/5a770e5c461462575474468e5af931cff9de036e7c2b4fea23c1c58d2cbe/pathable-0.5.0-py3-none-any.whl", hash = "sha256:646e3d09491a6351a0c82632a09c02cdf70a252e73196b36d8a15ba0a114f0a6", size = 16867, upload-time = "2026-02-20T08:46:59.536Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@ -242,6 +744,40 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-key-value-aio"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "beartype" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
filetree = [
|
||||
{ name = "aiofile" },
|
||||
{ name = "anyio" },
|
||||
]
|
||||
keyring = [
|
||||
{ name = "keyring" },
|
||||
]
|
||||
memory = [
|
||||
{ name = "cachetools" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.5"
|
||||
@ -257,6 +793,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
email = [
|
||||
{ name = "email-validator" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.41.5"
|
||||
@ -328,6 +869,20 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
@ -337,6 +892,29 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyperclip"
|
||||
version = "1.11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
@ -375,6 +953,40 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32"
|
||||
version = "311"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pywin32-ctypes"
|
||||
version = "0.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
@ -421,6 +1033,127 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich-rst"
|
||||
version = "1.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docutils" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936, upload-time = "2025-10-14T16:49:45.332Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567, upload-time = "2025-10-14T16:49:42.953Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "0.30.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.1"
|
||||
@ -455,12 +1188,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/ab/b281071bc11555670a82c538e4be35e7f5ad5e4cb4de7ac356a05a4b2c6a/schemdraw-0.22-py3-none-any.whl", hash = "sha256:fb294fe086b89a7dc9bedce43dc39014a9b9e369864eab438f22009c9f0e1175", size = 148359, upload-time = "2025-11-30T23:42:04.794Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secretstorage"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "jeepney" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spicebook"
|
||||
version = "2026.2.13"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "fastmcp" },
|
||||
{ name = "httpx" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "schemdraw" },
|
||||
@ -479,6 +1227,8 @@ 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" },
|
||||
@ -491,6 +1241,19 @@ 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"
|
||||
@ -695,3 +1458,12 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
|
||||
]
|
||||
|
||||
@ -19,8 +19,14 @@ services:
|
||||
- caddy
|
||||
labels:
|
||||
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
|
||||
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc"
|
||||
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc /mcp/*"
|
||||
caddy.reverse_proxy_0: "@api {{upstreams 8000}}"
|
||||
caddy.reverse_proxy_0.flush_interval: "-1"
|
||||
caddy.reverse_proxy_0.transport: "http"
|
||||
caddy.reverse_proxy_0.transport.read_timeout: "0"
|
||||
caddy.reverse_proxy_0.transport.write_timeout: "0"
|
||||
caddy.reverse_proxy_0.stream_timeout: "24h"
|
||||
caddy.reverse_proxy_0.stream_close_delay: "5s"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
|
||||
@ -11,8 +11,14 @@ services:
|
||||
- caddy
|
||||
labels:
|
||||
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
|
||||
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc"
|
||||
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc /mcp/*"
|
||||
caddy.reverse_proxy_0: "@api {{upstreams 8000}}"
|
||||
caddy.reverse_proxy_0.flush_interval: "-1"
|
||||
caddy.reverse_proxy_0.transport: "http"
|
||||
caddy.reverse_proxy_0.transport.read_timeout: "0"
|
||||
caddy.reverse_proxy_0.transport.write_timeout: "0"
|
||||
caddy.reverse_proxy_0.stream_timeout: "24h"
|
||||
caddy.reverse_proxy_0.stream_close_delay: "5s"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
|
||||
@ -15,6 +15,8 @@ services:
|
||||
build:
|
||||
context: ./frontend
|
||||
env_file: .env
|
||||
environment:
|
||||
- PUBLIC_API_URL=https://${SPICEBOOK_DOMAIN:-localhost:4321}
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
967
docs/reference-architecture-mcp-chat.md
Normal file
967
docs/reference-architecture-mcp-chat.md
Normal file
@ -0,0 +1,967 @@
|
||||
# Reference Architecture: MCP Server + SSE Chat on FastAPI
|
||||
|
||||
Pattern for adding an MCP server and a streaming chat assistant to an existing FastAPI application with any frontend framework. First built for the [Margaret Hamilton Digital Archive](https://hamilton.warehack.ing) (Starlight + vanilla JS + FastAPI), then adapted for [SpiceBook](https://spicebook.warehack.ing) (Astro SSR + React 19 + FastAPI). Both are in production.
|
||||
|
||||
---
|
||||
|
||||
## Origin Story
|
||||
|
||||
The Hamilton Archive needed a chat assistant that could answer questions about Apollo-era documents using RAG (retrieval-augmented generation). The requirements were:
|
||||
|
||||
1. **MCP server** — so Claude Code and other MCP clients could query the archive programmatically
|
||||
2. **Chat panel** — floating widget on all pages, streaming LLM responses via SSE, aware of whatever the user was currently reading (a Starlight page, a PDF in the viewer, etc.)
|
||||
3. **RAG pipeline** — semantic search → batch SQL fetch → character-budget truncation → LLM completion
|
||||
|
||||
This was built as vanilla TypeScript (no framework) because the Hamilton Archive uses Starlight with static output — there's no React, no Zustand, no build-time component hydration. The chat widget is a single 1,125-line `.ts` file that does manual DOM manipulation, localStorage conversation management, and inline Lucide SVG icon paths.
|
||||
|
||||
When the same pattern was needed for SpiceBook, the architecture was adapted:
|
||||
|
||||
- **Frontend**: React 19 with Zustand for state, split across `ChatWidget.tsx` + `chat-store.ts` + `chat-api.ts`
|
||||
- **Context model**: `PageContext(title, path, description)` → `NotebookContext(notebook_id, title, engine)` — the domain changed but the shape is identical
|
||||
- **RAG function**: `_build_context(query)` → `_build_notebook_context(req)` — this is the main customization point between deployments
|
||||
- **Caddy routing**: per-route `handle` blocks → single `@api.path` matcher — simpler but less precise
|
||||
|
||||
**What stays identical** across both projects:
|
||||
|
||||
| Component | Identical? | Notes |
|
||||
|-----------|:----------:|-------|
|
||||
| SSE event protocol | Yes | `status`, `token`, `reasoning`, `error`, `done` |
|
||||
| SSE client parser | Yes | `parseSSEBlock()` with `\n\n` boundary detection |
|
||||
| `_sse_event()` helper | Yes | Compact JSON formatting |
|
||||
| httpx streaming client | Yes | Same timeouts, limits, connection pooling |
|
||||
| `_chat_completion_stream()` | Yes | Same SSE line parser for OpenAI-compatible endpoints |
|
||||
| MCP mounting pattern | Yes | `mcp.http_app()` + `combine_lifespans()` + `app.mount("/mcp", ...)` |
|
||||
| FastMCP tool conventions | Yes | Return `str` (JSON), never raise `HTTPException` |
|
||||
| Conversation limits | Yes | MAX_CONVERSATIONS=20, MAX_MESSAGES=50 |
|
||||
| Title derivation | Yes | First user message truncated to ~50-60 chars |
|
||||
|
||||
---
|
||||
|
||||
## Two Frontend Variants
|
||||
|
||||
### Variant A: Vanilla TypeScript (Hamilton Archive)
|
||||
|
||||
**Single file**: `chat-widget.ts` (1,125 lines) — no framework, no build-time hydration, no npm state library.
|
||||
|
||||
**Entry point**:
|
||||
```typescript
|
||||
// ChatWidget.astro (11 lines)
|
||||
import { initChatWidget } from './chat-widget.ts'
|
||||
initChatWidget()
|
||||
document.addEventListener('astro:after-swap', initChatWidget)
|
||||
```
|
||||
|
||||
**State management**: Module-scoped variables + direct localStorage:
|
||||
```typescript
|
||||
const STORAGE_KEY_INDEX = 'hamilton-chat-conversations'
|
||||
const STORAGE_KEY_ACTIVE = 'hamilton-chat-active'
|
||||
const STORAGE_KEY_PREFIX = 'hamilton-chat-conv-'
|
||||
const STORAGE_KEY_LEGACY = 'hamilton-chat-history' // flat format, auto-migrated
|
||||
```
|
||||
|
||||
Storage uses a split architecture: an index array (conversation metadata) stored separately from individual conversation message arrays (`STORAGE_KEY_PREFIX + id`). This avoids loading all message content when just rendering the history list.
|
||||
|
||||
**DOM manipulation**: `data-open`/`data-view` attributes on the widget root element control CSS visibility. Rendering is imperative — `renderMessages()`, `renderHistoryList()`, etc. Lucide icons are pasted as inline SVG path strings (no icon library dependency).
|
||||
|
||||
**Key advantage**: Zero JS framework overhead. The widget works in any static site (Starlight, plain HTML, Hugo) because it only needs a `<script>` tag.
|
||||
|
||||
**Key disadvantage**: All UI logic (event listeners, DOM updates, scroll management, thinking indicators) lives in one file. A conversation switch requires careful manual capture of `streamingText` before aborting the SSE stream. React's declarative model handles this more cleanly.
|
||||
|
||||
### Variant B: React + Zustand (SpiceBook)
|
||||
|
||||
**Three files**: `ChatWidget.tsx` (component), `chat-store.ts` (state), `chat-api.ts` (network).
|
||||
|
||||
**State management**: Zustand with `persist` middleware:
|
||||
```typescript
|
||||
export const useChatStore = create<ChatStore>()(
|
||||
persist(
|
||||
(set, get) => ({ /* actions */ }),
|
||||
{
|
||||
name: 'spicebook-chat',
|
||||
partialize: (state) => ({
|
||||
conversations: state.conversations,
|
||||
activeConversationId: state.activeConversationId,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
The `partialize` function excludes transient state (`panelOpen`, `streaming`) from persistence — only conversation data survives page reloads.
|
||||
|
||||
**SSE client**: Separate async generator in `chat-api.ts`:
|
||||
```typescript
|
||||
export async function* streamChat(opts: ChatStreamOptions): AsyncGenerator<SSEEvent> {
|
||||
const resp = await fetch(`${API_BASE}/api/chat/stream`, { ... });
|
||||
const reader = resp.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
// ... parse SSE blocks by \n\n boundary
|
||||
}
|
||||
```
|
||||
|
||||
**Context awareness**: Reads from the notebook store (`useNotebookStore`) to pass `NotebookContext` to the API:
|
||||
```typescript
|
||||
interface ChatStreamOptions {
|
||||
question: string;
|
||||
notebook?: { notebook_id: string; title: string; engine: string } | null;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
```
|
||||
|
||||
**Key advantage**: Declarative state updates. `appendToLastAssistant(chunk)` immutably updates the conversation array — no manual DOM sync needed.
|
||||
|
||||
**Key disadvantage**: Requires React hydration. Uses `client:load` in Astro, which means the widget JS downloads and executes on every page load.
|
||||
|
||||
---
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### 1. MCP Server Module
|
||||
|
||||
Both projects use the same layout:
|
||||
|
||||
```
|
||||
backend/src/myapp/
|
||||
mcp/
|
||||
__init__.py # FastMCP singleton + import side-effects
|
||||
tools.py # @mcp.tool() functions wrapping domain logic
|
||||
chat.py # (Hamilton only) RAG tool + LLM client
|
||||
```
|
||||
|
||||
**Hamilton** registers tools in **two files**: `mcp/tools.py` (search, browse, stats) AND `mcp/chat.py` (the `ask_hamilton` RAG tool + LLM client). The RAG tool lives alongside the streaming client because they share `_build_context()` and `_chat_completion()`.
|
||||
|
||||
**SpiceBook** keeps **all tools in `mcp/tools.py`** and the LLM client in a separate `chat/llm.py` module. The chat backend doesn't register any MCP tools — it's purely an HTTP endpoint.
|
||||
|
||||
```python
|
||||
# Hamilton: mcp/__init__.py
|
||||
import hamilton_search.mcp.chat # Registers ask_hamilton tool
|
||||
import hamilton_search.mcp.tools # Registers search_archive, get_document, etc.
|
||||
|
||||
# SpiceBook: mcp/__init__.py
|
||||
import spicebook.mcp.tools # Registers list_all_notebooks, simulate_netlist, etc.
|
||||
```
|
||||
|
||||
**Hamilton MCP tools use `Literal` types** for constrained parameters:
|
||||
```python
|
||||
ContentType = Literal[
|
||||
"page", "paper_summary", "source_note", "essay",
|
||||
"archive", "agc_source", "apollo_context", "agc_highlight",
|
||||
]
|
||||
|
||||
@mcp.tool()
|
||||
async def search_archive(
|
||||
query: str,
|
||||
mode: Literal["hybrid", "semantic", "text"] = "hybrid",
|
||||
content_type: ContentType | None = None,
|
||||
limit: int = 10,
|
||||
) -> str:
|
||||
```
|
||||
|
||||
SpiceBook tools use plain `str` parameters for engine names because there are only two options (`ngspice`, `ltspice`) and validation happens in the engine factory.
|
||||
|
||||
### 2. Context Building — The Main Customization Point
|
||||
|
||||
The `_build_context()` function is where each deployment diverges. Everything else (SSE framing, LLM streaming, MCP mounting) is reusable.
|
||||
|
||||
#### Hamilton: `_build_context(query)` — RAG with Semantic Search
|
||||
|
||||
```python
|
||||
async def _build_context(query: str) -> tuple[str, list[dict]]:
|
||||
"""Search archive and batch-fetch full document bodies for RAG context."""
|
||||
async with async_session() as db:
|
||||
# 1. Hybrid search (semantic + text) for top 5 results
|
||||
output = await search_documents(q=query, db=db, mode="hybrid", limit=5)
|
||||
|
||||
# 2. Batch fetch all documents in one SQL query
|
||||
slugs = [r.slug for r in output.results]
|
||||
docs_result = await db.execute(
|
||||
select(Document).where(Document.slug.in_(slugs))
|
||||
)
|
||||
docs_by_slug = {doc.slug: doc for doc in docs_result.scalars()}
|
||||
|
||||
# 3. Build context string with character-budget truncation
|
||||
context_parts = []
|
||||
chars_used = 0
|
||||
for slug in slugs: # preserve search result ordering
|
||||
doc = docs_by_slug.get(slug)
|
||||
remaining = MAX_CONTEXT_CHARS - chars_used # MAX_CONTEXT_CHARS = 2000
|
||||
if remaining <= 0:
|
||||
break
|
||||
body = doc.body[:remaining] + "..." if len(doc.body) > remaining else doc.body
|
||||
context_parts.append(f"--- {doc.title} (/{doc.slug}) ---\n{body}")
|
||||
chars_used += len(body)
|
||||
|
||||
return "\n\n".join(context_parts), sources
|
||||
```
|
||||
|
||||
**Pattern**: Search → batch fetch → budget truncation. The two-phase fetch (search for slugs, then `SELECT ... WHERE slug IN (...)`) avoids N+1 queries. Character-budget truncation preserves document ordering from search relevance while staying within LLM context limits.
|
||||
|
||||
**Returns**: `(context_text, sources_list)` — the sources list is forwarded to the frontend as an SSE `sources` event so the chat widget can render clickable links.
|
||||
|
||||
#### SpiceBook: `_build_notebook_context(req)` — Notebook Content Extraction
|
||||
|
||||
```python
|
||||
async def _build_notebook_context(req: ChatStreamRequest) -> str:
|
||||
"""Extract SPICE cells and markdown notes from the notebook."""
|
||||
if not req.notebook or not req.notebook.notebook_id:
|
||||
return ""
|
||||
|
||||
nb = await asyncio.to_thread(load_notebook, settings.notebook_dir, req.notebook.notebook_id)
|
||||
if nb is None:
|
||||
return ""
|
||||
|
||||
parts = [f'Notebook: "{nb.metadata.title}" (engine: {nb.metadata.engine})']
|
||||
|
||||
for i, cell in enumerate(nb.cells):
|
||||
if cell.type.value == "spice" and cell.source.strip():
|
||||
parts.append(f"\n--- SPICE Cell {i + 1} ---\n{cell.source.strip()}")
|
||||
# Include latest simulation result summary
|
||||
for output in cell.outputs:
|
||||
if output.output_type in ("simulation_result", "error"):
|
||||
if not output.data.get("success") and output.data.get("error"):
|
||||
parts.append(f" [Simulation error: {output.data['error']}]")
|
||||
elif output.data.get("success") and output.data.get("waveform"):
|
||||
wf = output.data["waveform"]
|
||||
var_names = [v.get("name", "") for v in wf.get("variables", [])]
|
||||
parts.append(f" [Simulation OK: {wf.get('points')} points, signals: {', '.join(var_names)}]")
|
||||
break
|
||||
elif cell.type.value == "markdown" and cell.source.strip():
|
||||
parts.append(f"\n--- Notes ---\n{cell.source.strip()[:500]}")
|
||||
|
||||
return "\n".join(parts)
|
||||
```
|
||||
|
||||
**Pattern**: Load → iterate cells → extract domain content. No search step — the user is already viewing a specific notebook, so we load it directly and extract the SPICE netlists plus their latest simulation results. Markdown notes are truncated to 500 chars.
|
||||
|
||||
**Returns**: Just a `str` — no sources list because there's nothing to cite. The context is the notebook itself.
|
||||
|
||||
> **Async I/O**: Note `asyncio.to_thread(load_notebook, ...)` — `load_notebook()` uses synchronous `path.read_text()`, which blocks the asyncio event loop. Without `to_thread()`, every chat request briefly freezes *all* other async handlers (health checks, notebook API, other chat streams). This was caught during live debugging when concurrent requests stalled.
|
||||
|
||||
### 3. Page Context — What the User Is Looking At
|
||||
|
||||
Both projects prepend context about what the user is currently viewing to the question string. The shapes differ but the pattern is identical.
|
||||
|
||||
#### Hamilton: `PageContext(title, path, description)`
|
||||
|
||||
```python
|
||||
class PageContext(BaseModel):
|
||||
title: str = Field("", max_length=200)
|
||||
path: str = Field("", max_length=500)
|
||||
description: str = Field("", max_length=500)
|
||||
|
||||
@field_validator("path")
|
||||
@classmethod
|
||||
def path_must_be_relative(cls, v: str) -> str:
|
||||
if v and not v.startswith("/"):
|
||||
raise ValueError("Path must start with /")
|
||||
return v
|
||||
```
|
||||
|
||||
Frontend detection is viewer-aware — Hamilton has a PDF viewer page that exposes `window.__hamiltonSourceMap` and `window.__pdfMetadata`. The `getPageContext()` function reads the current PDF page number from the viewer DOM, appends PDF metadata (author, creation date), and falls back to standard Starlight `<main h1>` detection:
|
||||
|
||||
```typescript
|
||||
function getPageContext(): { title: string; path: string; description: string } | null {
|
||||
if (location.pathname === '/viewer' || location.pathname === '/viewer/') {
|
||||
// Read from source map + viewer state
|
||||
const titleWithPage = `${docTitle} (page ${currentPage} of ${total})`;
|
||||
// ... append PDF metadata (Author, Created, Subject, Pages)
|
||||
return { title: titleWithPage, path, description };
|
||||
}
|
||||
// Standard Starlight page
|
||||
const title = document.querySelector('main h1')?.textContent?.trim();
|
||||
return { title, path: location.pathname, description: meta.content };
|
||||
}
|
||||
```
|
||||
|
||||
Backend prepends the context as a bracketed string:
|
||||
```python
|
||||
if req.page and req.page.title:
|
||||
page_context = f'[The user is currently reading: "{req.page.title}" ({req.page.path})'
|
||||
if req.page.description:
|
||||
page_context += f"\nDocument description: {req.page.description}"
|
||||
page_context += "]\n\n"
|
||||
question = page_context + req.question
|
||||
```
|
||||
|
||||
#### SpiceBook: `NotebookContext(notebook_id, title, engine)`
|
||||
|
||||
```python
|
||||
class NotebookContext(BaseModel):
|
||||
notebook_id: str = Field("", max_length=200)
|
||||
title: str = Field("", max_length=200)
|
||||
engine: str = Field("ngspice", max_length=50)
|
||||
```
|
||||
|
||||
No path validation needed — notebook IDs are used for `load_notebook()`, which already validates against the filesystem. The backend prepends:
|
||||
```python
|
||||
if req.notebook and req.notebook.title:
|
||||
question = f'[User is viewing notebook: "{nb_title}" ({nb_engine})]\n\n' + req.question
|
||||
```
|
||||
|
||||
### 4. System Prompt
|
||||
|
||||
Both use `/no_think\n` as the first line to suppress reasoning on models that support it (e.g., Qwen3). This saves tokens since the chat assistant doesn't need to show its reasoning process.
|
||||
|
||||
```python
|
||||
# Hamilton
|
||||
SYSTEM_PROMPT = (
|
||||
"/no_think\n"
|
||||
"You are a knowledgeable research assistant for the Margaret Hamilton Digital Archive. "
|
||||
"Answer questions using ONLY the provided context from the archive. "
|
||||
"If the context doesn't contain enough information, say so clearly. "
|
||||
"Cite specific documents by title when referencing information. "
|
||||
"Be precise and factual — never fabricate quotes or claims."
|
||||
)
|
||||
|
||||
# SpiceBook
|
||||
SYSTEM_PROMPT = (
|
||||
"/no_think\n"
|
||||
"You are a circuit simulation assistant integrated into SpiceBook, "
|
||||
"a notebook environment for SPICE circuit design and simulation. "
|
||||
"Help users understand circuits, debug netlists, interpret simulation "
|
||||
"results, and design new circuits. ..."
|
||||
)
|
||||
```
|
||||
|
||||
### 5. SSE Streaming Endpoint
|
||||
|
||||
The endpoint structure is identical. Hamilton adds a `sources` event and has a pre-search status flow; SpiceBook skips straight to streaming.
|
||||
|
||||
```python
|
||||
# Hamilton: yields status → sources → tokens → done
|
||||
async def generate():
|
||||
yield _sse_event("status", {"text": "Searching the archive…"})
|
||||
context, sources = await _build_context(question)
|
||||
yield _sse_event("status", {"text": f"Found {n} relevant documents…"})
|
||||
yield _sse_event("sources", sources) # ← Hamilton-specific
|
||||
async for kind, text in _chat_completion_stream(context, question):
|
||||
yield _sse_event("reasoning" if kind == "reasoning" else "token", {"text": text})
|
||||
yield _sse_event("done", {})
|
||||
|
||||
# SpiceBook: yields status → tokens → done
|
||||
async def generate():
|
||||
context = _build_notebook_context(req)
|
||||
yield _sse_event("status", {"text": "Analyzing circuit context…"})
|
||||
async for kind, text in chat_completion_stream(context, question):
|
||||
yield _sse_event("reasoning" if kind == "reasoning" else "token", {"text": text})
|
||||
yield _sse_event("done", {})
|
||||
```
|
||||
|
||||
### 6. Mount MCP on FastAPI
|
||||
|
||||
```python
|
||||
from contextlib import asynccontextmanager
|
||||
from fastmcp.utilities.lifespan import combine_lifespans
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
await close_client() # Clean up httpx client
|
||||
|
||||
mcp_app = mcp.http_app(path="/", stateless_http=True)
|
||||
|
||||
app = FastAPI(
|
||||
title="MyApp",
|
||||
lifespan=combine_lifespans(lifespan, mcp_app.lifespan),
|
||||
)
|
||||
|
||||
# Register routers FIRST
|
||||
app.include_router(chat_router)
|
||||
app.include_router(other_router)
|
||||
|
||||
# Mount MCP LAST (catch-all)
|
||||
app.mount("/mcp", mcp_app)
|
||||
```
|
||||
|
||||
> **Critical ordering**: `include_router()` before `app.mount("/mcp", ...)`. FastAPI mounts are catch-all — if MCP is mounted first, it swallows routes that share a prefix.
|
||||
|
||||
### 7. Domain Error Decoupling
|
||||
|
||||
Domain logic must not raise `HTTPException` — it breaks MCP tools which don't run through FastAPI's exception handling. Use domain-specific exceptions:
|
||||
|
||||
```python
|
||||
# Domain layer: raises ValueError
|
||||
def get_engine(name: str) -> Engine:
|
||||
if name not in ENGINES:
|
||||
raise UnsupportedEngineError(f"Unsupported: '{name}'")
|
||||
|
||||
# HTTP router: converts at boundary
|
||||
try:
|
||||
engine = get_engine(req.engine)
|
||||
except UnsupportedEngineError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
# MCP tool: lets ValueError propagate — FastMCP converts to MCP error
|
||||
@mcp.tool()
|
||||
async def simulate(netlist: str, engine: str = "ngspice") -> str:
|
||||
eng = get_engine(engine) # ValueError propagates naturally
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conversation Management Patterns
|
||||
|
||||
### Hamilton: Manual localStorage with Legacy Migration
|
||||
|
||||
Hamilton stores conversations in a split format: an index array of metadata and individual conversation arrays keyed by ID. It also migrates from an older flat format:
|
||||
|
||||
```typescript
|
||||
function migrateFromLegacy(): void {
|
||||
const raw = localStorage.getItem(STORAGE_KEY_LEGACY);
|
||||
if (!raw) return;
|
||||
// Parse flat message array → create new conversation → save to indexed format
|
||||
localStorage.removeItem(STORAGE_KEY_LEGACY);
|
||||
}
|
||||
```
|
||||
|
||||
**Title derivation** uses `deriveTitle()` — takes the first user message, truncates to `TITLE_MAX_LENGTH` (60 chars), and updates the index entry on save. The title stays "New conversation" until the first `saveHistory()` call.
|
||||
|
||||
**Two-click delete with auto-revert**:
|
||||
|
||||
```typescript
|
||||
function handleDelete(id: string): void {
|
||||
if (pendingDeleteId === id) {
|
||||
// Second click within 3s — confirm deletion
|
||||
clearPendingDelete();
|
||||
performDelete(id);
|
||||
} else {
|
||||
// First click — show "Delete?" label, start 3s timer
|
||||
clearPendingDelete();
|
||||
pendingDeleteId = id;
|
||||
pendingDeleteTimer = window.setTimeout(() => {
|
||||
pendingDeleteId = null;
|
||||
pendingDeleteTimer = null;
|
||||
if (viewMode === 'history') renderHistoryList(); // revert UI
|
||||
}, 3000);
|
||||
renderHistoryList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This prevents accidental deletions without a modal dialog. The 3-second auto-revert means the user doesn't have to click "cancel" if they change their mind.
|
||||
|
||||
### SpiceBook: Zustand `persist` Middleware
|
||||
|
||||
SpiceBook's `useChatStore` handles the same patterns declaratively:
|
||||
|
||||
```typescript
|
||||
createConversation: () => {
|
||||
const id = generateId();
|
||||
set((s) => ({
|
||||
conversations: [conv, ...s.conversations].slice(0, MAX_CONVERSATIONS),
|
||||
activeConversationId: id,
|
||||
}));
|
||||
return id;
|
||||
},
|
||||
|
||||
addUserMessage: (text: string) => {
|
||||
set((s) => ({
|
||||
conversations: s.conversations.map((c) => {
|
||||
if (c.id !== convId) return c;
|
||||
const title = c.messages.length === 0 ? titleFromQuestion(text) : c.title;
|
||||
return { ...c, messages: [...c.messages, msg].slice(-MAX_MESSAGES), title, updatedAt: now };
|
||||
}),
|
||||
}));
|
||||
},
|
||||
```
|
||||
|
||||
Zustand's `persist` middleware handles serialization and localStorage automatically. The `partialize` function excludes transient state. Deletion is a simple `filter()`.
|
||||
|
||||
### SSE Stream State Capture (Hamilton-Specific Edge Case)
|
||||
|
||||
Hamilton captures `streamingText` at widget scope so conversation switches can save partial text before aborting:
|
||||
|
||||
```typescript
|
||||
// Module-level state (not per-conversation)
|
||||
let streamingText = '';
|
||||
let streamingSources: ChatSource[] = [];
|
||||
let conversationSwitchInProgress = false;
|
||||
|
||||
function startNewConversation(): void {
|
||||
// Capture partial streaming text BEFORE aborting
|
||||
if (abortController && streamingText) {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
text: streamingText,
|
||||
sources: streamingSources.length ? streamingSources : undefined,
|
||||
});
|
||||
streamingText = '';
|
||||
streamingSources = [];
|
||||
}
|
||||
|
||||
if (abortController) {
|
||||
conversationSwitchInProgress = true; // suppress error handler
|
||||
abortController.abort();
|
||||
abortController = null;
|
||||
}
|
||||
|
||||
saveHistory();
|
||||
createNewConversation();
|
||||
messages = [];
|
||||
}
|
||||
```
|
||||
|
||||
The `conversationSwitchInProgress` flag is needed because aborting the SSE stream fires the error handler. Without the flag, the error handler would try to save a partial message to the *wrong* conversation (the one we just switched away from).
|
||||
|
||||
SpiceBook handles this more simply — React's `useRef` for the AbortController plus Zustand's immutable updates mean the conversation ID is captured in the closure, so there's no risk of writing to the wrong conversation.
|
||||
|
||||
---
|
||||
|
||||
## Caddy Configuration
|
||||
|
||||
### Hamilton: Per-Route `handle` Blocks
|
||||
|
||||
Hamilton uses numbered `caddy.handle_N` labels with independent reverse proxy configs per route:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
caddy: ${PUBLIC_DOMAIN:-hamilton.l.warehack.ing}
|
||||
|
||||
# Search API — standard proxy, no SSE
|
||||
caddy.handle: /api/search*
|
||||
caddy.handle.0_reverse_proxy: "{{upstreams 8000}}"
|
||||
|
||||
# Health endpoint
|
||||
caddy.handle_1: /health
|
||||
caddy.handle_1.0_reverse_proxy: "{{upstreams 8000}}"
|
||||
|
||||
# MCP endpoint — standard proxy
|
||||
caddy.handle_2: /mcp*
|
||||
caddy.handle_2.0_reverse_proxy: "{{upstreams 8000}}"
|
||||
|
||||
# Chat streaming — SSE-optimized proxy
|
||||
caddy.handle_3: /api/chat*
|
||||
caddy.handle_3.0_reverse_proxy: "{{upstreams 8000}}"
|
||||
caddy.handle_3.0_reverse_proxy.flush_interval: "-1"
|
||||
caddy.handle_3.0_reverse_proxy.transport: "http"
|
||||
caddy.handle_3.0_reverse_proxy.transport.read_timeout: "0"
|
||||
caddy.handle_3.0_reverse_proxy.transport.write_timeout: "0"
|
||||
```
|
||||
|
||||
**Advantage**: SSE streaming labels (`flush_interval`, `read_timeout`, `write_timeout`) only apply to `/api/chat*`. Non-streaming routes like `/api/search*` and `/mcp*` use Caddy's default buffering and timeouts, which is more efficient for short-lived requests.
|
||||
|
||||
### SpiceBook: Single `@api.path` Matcher
|
||||
|
||||
SpiceBook groups all backend routes under one path matcher:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
caddy: "${SPICEBOOK_DOMAIN:-spicebook.localhost}"
|
||||
|
||||
# All backend routes go through one matcher with SSE settings
|
||||
caddy.@api.path: "/api/* /health /docs /openapi.json /redoc /mcp/*"
|
||||
caddy.reverse_proxy_0: "@api {{upstreams 8000}}"
|
||||
caddy.reverse_proxy_0.flush_interval: "-1"
|
||||
caddy.reverse_proxy_0.transport: "http"
|
||||
caddy.reverse_proxy_0.transport.read_timeout: "0"
|
||||
caddy.reverse_proxy_0.transport.write_timeout: "0"
|
||||
caddy.reverse_proxy_0.stream_timeout: "24h"
|
||||
caddy.reverse_proxy_0.stream_close_delay: "5s"
|
||||
```
|
||||
|
||||
**Advantage**: Simpler — one block instead of four. Adding new API routes just means adding to the path list.
|
||||
|
||||
**Disadvantage**: SSE streaming settings apply to *all* backend routes, including `/health` and `/docs`. The `read_timeout: 0` means Caddy will never close idle connections to the health endpoint, which is wasteful (though harmless in practice).
|
||||
|
||||
### Comparison Table
|
||||
|
||||
| Aspect | Hamilton (`handle` blocks) | SpiceBook (`@api.path` matcher) |
|
||||
|--------|----------------------------|--------------------------------|
|
||||
| Label count | ~12 labels across 4 handles | ~8 labels in 1 matcher |
|
||||
| SSE scope | Only `/api/chat*` | All backend routes |
|
||||
| Adding routes | New `handle_N` block | Append to path list |
|
||||
| Timeout precision | Per-route control | Blanket settings |
|
||||
| Complexity | Higher | Lower |
|
||||
| Recommended for | Multi-protocol backends (SSE + gRPC + REST) | Simple REST + SSE backends |
|
||||
|
||||
### SSE-Required Labels (Both Approaches)
|
||||
|
||||
These labels are **mandatory** for SSE streaming through Caddy. Without them, Caddy buffers responses and/or times out long-lived connections:
|
||||
|
||||
```yaml
|
||||
caddy.reverse_proxy.flush_interval: "-1" # Disable response buffering
|
||||
caddy.reverse_proxy.transport: "http"
|
||||
caddy.reverse_proxy.transport.read_timeout: "0" # No read timeout
|
||||
caddy.reverse_proxy.transport.write_timeout: "0" # No write timeout
|
||||
caddy.reverse_proxy.stream_timeout: "24h" # WebSocket/SSE lifetime
|
||||
caddy.reverse_proxy.stream_close_delay: "5s" # Graceful close on reload
|
||||
```
|
||||
|
||||
Also set these headers on the `StreamingResponse`:
|
||||
```python
|
||||
return StreamingResponse(
|
||||
generate(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no", # Disable nginx/proxy buffering
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSE Event Protocol
|
||||
|
||||
Both projects use this consistent event protocol:
|
||||
|
||||
| Event | Payload | Meaning |
|
||||
|-------|---------|---------|
|
||||
| `status` | `{"text": "..."}` | Status message (e.g., "Thinking...", "Searching the archive...") |
|
||||
| `sources` | `[{title, slug, url, score}]` | Hamilton only: search results for citation links |
|
||||
| `token` | `{"text": "..."}` | Content token from the LLM |
|
||||
| `reasoning` | `{"text": "..."}` | Reasoning/thinking token (if model supports it) |
|
||||
| `error` | `{"text": "..."}` | Error message to display to user |
|
||||
| `done` | `{}` | Stream complete |
|
||||
|
||||
**SSE formatting**: Use `json.dumps(data, separators=(",",":"))` (compact, no spaces) to prevent newline fragility in SSE framing. A stray `\n` in the JSON payload would split the SSE block.
|
||||
|
||||
---
|
||||
|
||||
## Security Hardening
|
||||
|
||||
These were identified during Apollo code review of the Hamilton Archive and applied retroactively. Each fix below references the original vulnerability.
|
||||
|
||||
### 1. HTTP Client Race Condition
|
||||
|
||||
**Hamilton vulnerability**: `_get_chat_client()` was a **sync** function with no lock:
|
||||
|
||||
```python
|
||||
# VULNERABLE (Hamilton original)
|
||||
def _get_chat_client() -> httpx.AsyncClient:
|
||||
global _chat_client
|
||||
if _chat_client is None or _chat_client.is_closed:
|
||||
_chat_client = httpx.AsyncClient(...) # race condition here
|
||||
return _chat_client
|
||||
```
|
||||
|
||||
Two concurrent requests could both see `_chat_client is None`, both create a new client, and the first client would be leaked (never closed). Under load this causes connection pool exhaustion.
|
||||
|
||||
**Fix**: Use `asyncio.Lock()` with double-checked locking:
|
||||
|
||||
```python
|
||||
# FIXED (SpiceBook, applied back to reference)
|
||||
_client_lock = asyncio.Lock()
|
||||
|
||||
async def _get_chat_client() -> httpx.AsyncClient:
|
||||
global _chat_client
|
||||
if _chat_client is not None and not _chat_client.is_closed:
|
||||
return _chat_client
|
||||
async with _client_lock:
|
||||
if _chat_client is not None and not _chat_client.is_closed:
|
||||
return _chat_client
|
||||
_chat_client = httpx.AsyncClient(...)
|
||||
return _chat_client
|
||||
```
|
||||
|
||||
The double-check avoids acquiring the lock on every call — only the first caller (or after client closure) takes the lock.
|
||||
|
||||
### 2. Streaming Error Bodies
|
||||
|
||||
**Hamilton vulnerability**: Used `resp.raise_for_status()` inside `client.stream()` context:
|
||||
|
||||
```python
|
||||
# VULNERABLE (Hamilton original)
|
||||
async with client.stream("POST", url, ...) as resp:
|
||||
resp.raise_for_status() # ← error body is empty in streaming context
|
||||
```
|
||||
|
||||
Inside a streaming context, the response body hasn't been read yet. `raise_for_status()` creates an `HTTPStatusError` with an empty body, making it impossible to diagnose the upstream error.
|
||||
|
||||
**Fix**: Read the error body explicitly before raising:
|
||||
|
||||
```python
|
||||
# FIXED (SpiceBook)
|
||||
async with client.stream("POST", url, ...) as resp:
|
||||
if resp.status_code >= 400:
|
||||
body = await resp.aread()
|
||||
error_text = body[:500].decode("utf-8", errors="replace")
|
||||
logger.error("LLM gateway returned %d: %s", resp.status_code, error_text)
|
||||
raise httpx.HTTPStatusError(
|
||||
f"LLM gateway error {resp.status_code}",
|
||||
request=resp.request,
|
||||
response=resp,
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Bare Exception Handling in Stream
|
||||
|
||||
**Hamilton vulnerability**: Used bare `except Exception` in the streaming endpoint:
|
||||
|
||||
```python
|
||||
# VULNERABLE (Hamilton original)
|
||||
try:
|
||||
async for kind, text in _chat_completion_stream(context, question):
|
||||
yield _sse_event(...)
|
||||
except Exception:
|
||||
logger.exception("Chat stream completion failed")
|
||||
yield _sse_event("error", {"text": "Chat completion unavailable"})
|
||||
```
|
||||
|
||||
This catches `asyncio.CancelledError` (a `BaseException` subclass in Python 3.9+, but still caught by careless patterns), `KeyboardInterrupt`, and other exceptions that should propagate. It also swallows the traceback context for debugging.
|
||||
|
||||
**Fix**: Catch specific `httpx` exceptions:
|
||||
|
||||
```python
|
||||
# FIXED (SpiceBook)
|
||||
try:
|
||||
async for kind, text in chat_completion_stream(context, question):
|
||||
yield _sse_event(...)
|
||||
except (
|
||||
httpx.HTTPStatusError,
|
||||
httpx.ConnectError,
|
||||
httpx.ReadTimeout,
|
||||
httpx.PoolTimeout,
|
||||
httpx.ConnectTimeout,
|
||||
) as exc:
|
||||
logger.warning("Chat stream failed: %s", exc)
|
||||
yield _sse_event("error", {"text": "Chat service unavailable"})
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Chat stream cancelled by client disconnect")
|
||||
return
|
||||
```
|
||||
|
||||
### 4. Path Traversal Protection
|
||||
|
||||
**Hamilton context**: Not directly vulnerable because it uses SQL (slugs are database lookups, not file paths). But filesystem-based apps like SpiceBook that construct paths from user IDs need validation:
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
_SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,198}[a-z0-9]$")
|
||||
|
||||
def validate_item_id(item_id: str) -> str:
|
||||
if not _SAFE_ID_RE.match(item_id):
|
||||
raise ValueError(f"Invalid item ID: {item_id!r}")
|
||||
return item_id
|
||||
```
|
||||
|
||||
### 5. Compact SSE JSON
|
||||
|
||||
Use `json.dumps(data, separators=(",",":"))` in `_sse_event()` to prevent newline fragility. Hamilton's original used default separators (`", "`, `": "`) which are fine for most payloads but can introduce visual confusion in debugging.
|
||||
|
||||
### Security Checklist
|
||||
|
||||
- [ ] **HTTP client init**: `asyncio.Lock()` with double-checked locking for lazy singleton
|
||||
- [ ] **Streaming errors**: `resp.aread()` before raising on HTTP errors inside `client.stream()`
|
||||
- [ ] **Specific exceptions**: Catch named `httpx.*` exceptions, not bare `Exception`
|
||||
- [ ] **CancelledError handling**: Explicit `except asyncio.CancelledError` in SSE generators
|
||||
- [ ] **Path traversal**: Validate user-provided IDs with regex before constructing file paths
|
||||
- [ ] **Error decoupling**: Domain logic raises `ValueError`, not `HTTPException`
|
||||
- [ ] **Compact SSE JSON**: `json.dumps(data, separators=(",",":"))` to prevent newline fragility
|
||||
- [ ] **SSE response headers**: `Cache-Control: no-cache` + `X-Accel-Buffering: no`
|
||||
- [ ] **Input validation**: `max_length` on all string fields in Pydantic models
|
||||
- [ ] **Shutdown cleanup**: `close_client()` in lifespan shutdown to drain connection pool
|
||||
|
||||
---
|
||||
|
||||
## Frontend Lessons Learned
|
||||
|
||||
### 1. Markdown Rendering: Use a Library, Not Regex
|
||||
|
||||
LLM responses contain unpredictable markdown: headers, nested lists, tables, blockquotes, fenced code blocks with language tags, horizontal rules. Hand-rolled regex cannot handle all of this.
|
||||
|
||||
**Hamilton's approach** — lightweight regex in `renderMarkdown()`:
|
||||
```typescript
|
||||
// Hamilton: handles bold, italic, inline code, links, line breaks
|
||||
function renderMarkdown(text: string): string {
|
||||
let html = escapeHtml(text);
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/(?<!\*)\*([^*]+?)\*(?!\*)/g, '<em>$1</em>');
|
||||
html = html.replace(/`([^`]+?)`/g, '<code>$1</code>');
|
||||
// ... links, newlines
|
||||
return html;
|
||||
}
|
||||
```
|
||||
|
||||
This works for the Hamilton Archive because its RAG context produces shorter, less complex responses. It fails badly for SpiceBook where the LLM generates full tutorials with headers, numbered lists, tables, and code blocks.
|
||||
|
||||
**SpiceBook's approach** — `marked` + `DOMPurify`:
|
||||
```typescript
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
|
||||
function renderMarkdown(text: string): string {
|
||||
const raw = marked.parse(text, { async: false }) as string;
|
||||
return DOMPurify.sanitize(raw, { ADD_ATTR: ['target'] });
|
||||
}
|
||||
```
|
||||
|
||||
**Why both layers**: `marked` handles all GFM syntax (tables, task lists, fenced code). `DOMPurify` strips XSS vectors from the HTML output — critical because the result goes into `dangerouslySetInnerHTML`. The `ADD_ATTR: ['target']` preserves `target="_blank"` on links.
|
||||
|
||||
**Cost**: `marked` is ~13 KB gzipped with full GFM support. Worth it for any chat widget where LLM output is unpredictable.
|
||||
|
||||
**Recommendation**: Start with `marked` + `DOMPurify` in new projects. Drop to regex only if you control the LLM output format (e.g., RAG with structured templates).
|
||||
|
||||
### 2. React 19 Automatic Batching Breaks SSE Streaming
|
||||
|
||||
React 19's automatic batching coalesces all `setState` calls within an async function into a single render. When the `for await...of` SSE loop processes many tokens from one `reader.read()` chunk, React defers rendering until the loop yields to the event loop — which means the user sees nothing until the entire stream finishes.
|
||||
|
||||
**The symptom**: Chat shows the first partial render (from the initial SSE chunk boundary), then freezes, then shows everything at once when the stream ends.
|
||||
|
||||
**Root cause in React 19**:
|
||||
```typescript
|
||||
// This produces ONE render at stream end, not N renders per token:
|
||||
for await (const evt of streamChat({ question })) {
|
||||
if (evt.event === 'token') {
|
||||
appendToLastAssistant(evt.data.text); // setState call
|
||||
// React batches this ↑ — no re-render happens here
|
||||
}
|
||||
}
|
||||
// React renders ONCE here, after the loop exits
|
||||
```
|
||||
|
||||
**Fix — `requestAnimationFrame` token batching**:
|
||||
|
||||
Accumulate tokens in a `useRef` (no re-render per token), then flush to Zustand state once per animation frame (~60fps). This gives smooth incremental streaming without overwhelming React:
|
||||
|
||||
```typescript
|
||||
// Refs (no re-render on write)
|
||||
const pendingTokensRef = useRef('');
|
||||
const flushRafRef = useRef(0);
|
||||
|
||||
// Inside the SSE event loop:
|
||||
case 'token':
|
||||
pendingTokensRef.current += evt.data.text;
|
||||
if (!flushRafRef.current) {
|
||||
flushRafRef.current = requestAnimationFrame(() => {
|
||||
if (pendingTokensRef.current) {
|
||||
appendToLastAssistant(pendingTokensRef.current);
|
||||
pendingTokensRef.current = '';
|
||||
}
|
||||
flushRafRef.current = 0;
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
// In the finally block — flush remaining buffered tokens:
|
||||
finally {
|
||||
if (flushRafRef.current) {
|
||||
cancelAnimationFrame(flushRafRef.current);
|
||||
flushRafRef.current = 0;
|
||||
}
|
||||
if (pendingTokensRef.current) {
|
||||
appendToLastAssistant(pendingTokensRef.current);
|
||||
pendingTokensRef.current = '';
|
||||
}
|
||||
setStreaming(false);
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works**: `requestAnimationFrame` fires once per display frame (~16ms at 60fps). Multiple tokens arriving within one frame get concatenated in the ref and flushed as a single `appendToLastAssistant` call — which triggers exactly one React render per frame. The browser gets to paint between frames, so the user sees smooth incremental text.
|
||||
|
||||
**Why `useRef` instead of `useState`**: Writing to a ref doesn't trigger a render. If we used `useState` for the accumulator, we'd be back to the same batching problem.
|
||||
|
||||
**Hamilton doesn't need this** because its vanilla JS `sendQuestion()` function writes directly to `bubble.innerHTML` — no framework batching layer.
|
||||
|
||||
### 3. Streaming Verification Checklist
|
||||
|
||||
When SSE streaming appears broken, check each layer in order. The issue is usually at exactly one layer:
|
||||
|
||||
| Layer | Check | Symptom if broken |
|
||||
|-------|-------|-------------------|
|
||||
| **GPU gateway** | `curl -N $GPU_BASE_URL/chat/completions` with `"stream": true` | No tokens arrive at all |
|
||||
| **Backend httpx** | `print()` inside `aiter_lines()` loop | Tokens arrive at backend but not forwarded |
|
||||
| **Backend sync I/O** | Check for `path.read_text()`, `open().read()` in async functions | First token delayed by seconds; concurrent requests stall |
|
||||
| **FastAPI StreamingResponse** | `curl -N http://localhost:8099/api/chat/stream` | Tokens stream from backend but not through proxy |
|
||||
| **Caddy proxy** | Check `flush_interval: "-1"` label | All tokens arrive at once when stream ends |
|
||||
| **Frontend fetch/reader** | `console.log()` in `reader.read()` loop | Tokens arrive in browser but UI doesn't update |
|
||||
| **React rendering** | Check for RAF batching pattern | UI updates once at stream end (React 19 batching) |
|
||||
|
||||
**The `asyncio.to_thread()` gotcha**: Any synchronous I/O in an `async def` function blocks the entire event loop. This doesn't just delay the current request — it freezes *all* concurrent async handlers (health checks, other chat streams, notebook API). Wrap sync I/O with `asyncio.to_thread()`:
|
||||
|
||||
```python
|
||||
# BROKEN: blocks event loop during file read
|
||||
async def _build_notebook_context(req):
|
||||
nb = load_notebook(settings.notebook_dir, req.notebook.notebook_id) # sync!
|
||||
|
||||
# FIXED: runs sync I/O in a thread pool worker
|
||||
async def _build_notebook_context(req):
|
||||
nb = await asyncio.to_thread(load_notebook, settings.notebook_dir, req.notebook.notebook_id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
```env
|
||||
# GPU LLM gateway
|
||||
GPU_API_KEY=your-api-key
|
||||
GPU_BASE_URL=https://your-app.gpu.supported.systems/v1
|
||||
GPU_CHAT_MODEL=qwen3
|
||||
CHAT_MAX_TOKENS=8192
|
||||
```
|
||||
|
||||
### Docker Compose: `environment` vs `env_file`
|
||||
|
||||
`env_file` passes values **literally** from the `.env` file — no variable interpolation. The `environment` section supports `${VAR}` interpolation from the host environment and the `.env` file.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frontend:
|
||||
env_file: .env # Passes GPU_API_KEY=abc123 literally
|
||||
environment:
|
||||
# Interpolates SPICEBOOK_DOMAIN from .env, with fallback
|
||||
- PUBLIC_API_URL=https://${SPICEBOOK_DOMAIN:-localhost:4321}
|
||||
```
|
||||
|
||||
This matters when a build-time variable (like `PUBLIC_API_URL`) needs to be constructed from a runtime variable (like `SPICEBOOK_DOMAIN`). You can't do string interpolation inside `env_file` — you need the `environment` section.
|
||||
|
||||
### Dependencies
|
||||
|
||||
Add to `pyproject.toml`:
|
||||
```toml
|
||||
dependencies = [
|
||||
"fastmcp>=3.0.0",
|
||||
"httpx>=0.28.0",
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Summary Template
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `backend/src/myapp/mcp/__init__.py` | FastMCP singleton + tool import side-effects |
|
||||
| `backend/src/myapp/mcp/tools.py` | MCP tool definitions wrapping domain logic |
|
||||
| `backend/src/myapp/chat/llm.py` | httpx LLM client with connection pooling + streaming |
|
||||
| `backend/src/myapp/models/chat.py` | Pydantic request models (ViewContext, ChatStreamRequest) |
|
||||
| `backend/src/myapp/routers/chat.py` | SSE streaming endpoint + context builder |
|
||||
| `backend/src/myapp/main.py` | Lifespan + MCP mount + router registration |
|
||||
| `frontend/src/lib/chat-api.ts` | SSE async generator client |
|
||||
| `frontend/src/lib/chat-store.ts` | Zustand store with localStorage persistence |
|
||||
| `frontend/src/components/chat/ChatWidget.tsx` | React floating chat panel (SpiceBook) |
|
||||
| `frontend/src/components/chat-widget.ts` | Vanilla TS chat widget (Hamilton) |
|
||||
| `docker-compose.yml` | Caddy labels for MCP + SSE routing |
|
||||
| `.env` | GPU gateway config |
|
||||
|
||||
---
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
# 1. MCP protocol test
|
||||
curl -X POST http://localhost:8099/mcp \
|
||||
-H 'Content-Type: application/json'
|
||||
|
||||
# 2. Chat SSE test
|
||||
curl -N -X POST http://localhost:8099/api/chat/stream \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"question":"Hello, what can you help with?"}'
|
||||
|
||||
# 3. Register MCP server with Claude Code
|
||||
claude mcp add myapp-local -- \
|
||||
uv run --directory /path/to/backend myapp
|
||||
|
||||
# 4. Test MCP tools via Claude Code
|
||||
claude -p "List all items" \
|
||||
--mcp-config .mcp.json \
|
||||
--allowedTools "mcp__myapp-local__*"
|
||||
```
|
||||
@ -24,6 +24,10 @@ export default defineConfig({
|
||||
optimizeDeps: { exclude: ['@resvg/resvg-js'] },
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
// Allow the Caddy reverse proxy domain (Vite 7+ blocks unknown Host headers)
|
||||
...(process.env.SPICEBOOK_DOMAIN && {
|
||||
allowedHosts: [process.env.SPICEBOOK_DOMAIN],
|
||||
}),
|
||||
...(process.env.VITE_HMR_HOST && {
|
||||
hmr: {
|
||||
host: process.env.VITE_HMR_HOST,
|
||||
|
||||
18
frontend/components.json
Normal file
18
frontend/components.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/cn",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib"
|
||||
}
|
||||
}
|
||||
958
frontend/package-lock.json
generated
958
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -23,13 +23,24 @@
|
||||
"@iconify-json/lucide": "^1.2.90",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@lezer/lr": "^1.4.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"astro": "^5.0.0",
|
||||
"astro-icon": "^1.1.5",
|
||||
"astro-seo-meta": "^5.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"dompurify": "^3.3.1",
|
||||
"katex": "^0.16.33",
|
||||
"lucide-react": "^0.468.0",
|
||||
"marked": "^17.0.3",
|
||||
"marked-katex-extension": "^5.1.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"satori": "^0.19.2",
|
||||
|
||||
91
frontend/src/components/chat/ChatInput.tsx
Normal file
91
frontend/src/components/chat/ChatInput.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { Send, Square, BookOpen } from 'lucide-react';
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSend: () => void;
|
||||
onAbort: () => void;
|
||||
streaming: boolean;
|
||||
notebookTitle?: string | null;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
value,
|
||||
onChange,
|
||||
onSend,
|
||||
onAbort,
|
||||
streaming,
|
||||
notebookTitle,
|
||||
}: ChatInputProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
|
||||
}, [value]);
|
||||
|
||||
// Focus on mount
|
||||
useEffect(() => {
|
||||
setTimeout(() => textareaRef.current?.focus(), 100);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (!streaming && value.trim()) {
|
||||
onSend();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSend, streaming, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="chat-page-input-container">
|
||||
{notebookTitle && (
|
||||
<div className="chat-page-input-context">
|
||||
<BookOpen size={12} />
|
||||
<span>{notebookTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-page-input-row">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="chat-page-textarea"
|
||||
placeholder="Ask about circuits, netlists, or simulation…"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={streaming}
|
||||
rows={1}
|
||||
/>
|
||||
{streaming ? (
|
||||
<button
|
||||
className="chat-page-send-btn abort"
|
||||
onClick={onAbort}
|
||||
title="Stop generating"
|
||||
>
|
||||
<Square size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="chat-page-send-btn"
|
||||
onClick={onSend}
|
||||
disabled={!value.trim()}
|
||||
title="Send (Enter)"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-page-input-hint">
|
||||
<span>Enter to send, Shift+Enter for newline</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/chat/ChatMessages.tsx
Normal file
84
frontend/src/components/chat/ChatMessages.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ChatMessage } from '../../lib/chat-store';
|
||||
import { renderMarkdown } from '../../lib/chat-render';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
||||
function MessageBubble({ msg, isStreaming }: { msg: ChatMessage; isStreaming?: boolean }) {
|
||||
return (
|
||||
<div className={`chat-page-bubble ${msg.role}`}>
|
||||
{msg.role === 'assistant' ? (
|
||||
<>
|
||||
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.text) }} />
|
||||
{isStreaming && <span className="chat-page-cursor" />}
|
||||
</>
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{msg.text}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: ChatMessage[];
|
||||
streaming: boolean;
|
||||
statusText: string;
|
||||
reasoningText: string;
|
||||
reasoningTime: number;
|
||||
}
|
||||
|
||||
export default function ChatMessages({
|
||||
messages,
|
||||
streaming,
|
||||
statusText,
|
||||
reasoningText,
|
||||
reasoningTime,
|
||||
}: ChatMessagesProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = bottomRef.current?.parentElement;
|
||||
if (container) {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
}, [messages.length, streaming]);
|
||||
|
||||
return (
|
||||
<div className="chat-page-messages">
|
||||
{messages.length === 0 && !streaming && (
|
||||
<div className="chat-page-empty">
|
||||
<div className="chat-page-empty-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-[var(--color-sb-text-bright)]">Circuit Assistant</h2>
|
||||
<p className="text-sm text-[var(--color-sb-muted)] max-w-md text-center">
|
||||
Ask about circuits, netlists, SPICE simulation, or component behavior.
|
||||
Select a notebook for context-aware answers.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble
|
||||
key={`${msg.timestamp}-${i}`}
|
||||
msg={msg}
|
||||
isStreaming={streaming && i === messages.length - 1 && msg.role === 'assistant'}
|
||||
/>
|
||||
))}
|
||||
|
||||
{reasoningText && streaming && (
|
||||
<details className="chat-page-reasoning">
|
||||
<summary>Thinking{reasoningTime > 0 ? ` (${reasoningTime}s)` : '…'}</summary>
|
||||
<div className="chat-page-reasoning-body">{reasoningText}</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{statusText && (
|
||||
<div className="chat-page-status">{statusText}</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
frontend/src/components/chat/ChatPage.tsx
Normal file
129
frontend/src/components/chat/ChatPage.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Zap, PanelLeftClose, PanelLeft } from 'lucide-react';
|
||||
import { useChatStore } from '../../lib/chat-store';
|
||||
import { useChatStream } from '../../lib/use-chat-stream';
|
||||
import { Sheet, SheetContent, SheetTitle } from '../ui/sheet';
|
||||
import { TooltipProvider } from '../ui/tooltip';
|
||||
import ChatSidebar from './ChatSidebar';
|
||||
import ChatMessages from './ChatMessages';
|
||||
import ChatInput from './ChatInput';
|
||||
import '../../styles/chat-page.css';
|
||||
|
||||
export default function ChatPage() {
|
||||
const getActiveConversation = useChatStore((s) => s.getActiveConversation);
|
||||
const createConversation = useChatStore((s) => s.createConversation);
|
||||
const selectedNotebookId = useChatStore((s) => s.selectedNotebookId);
|
||||
const selectedNotebookMeta = useChatStore((s) => s.selectedNotebookMeta);
|
||||
|
||||
// Build notebook override for the streaming hook
|
||||
const notebookOverride = selectedNotebookId && selectedNotebookMeta
|
||||
? {
|
||||
notebook_id: selectedNotebookId,
|
||||
title: selectedNotebookMeta.title,
|
||||
engine: selectedNotebookMeta.engine,
|
||||
}
|
||||
: null;
|
||||
|
||||
const {
|
||||
sendMessage,
|
||||
abort,
|
||||
streaming,
|
||||
statusText,
|
||||
reasoningText,
|
||||
reasoningTime,
|
||||
} = useChatStream({ notebookOverride });
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [desktopSidebarVisible, setDesktopSidebarVisible] = useState(true);
|
||||
|
||||
const activeConv = getActiveConversation();
|
||||
const messages = activeConv?.messages ?? [];
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const question = input.trim();
|
||||
if (!question || streaming) return;
|
||||
setInput('');
|
||||
sendMessage(question);
|
||||
}, [input, streaming, sendMessage]);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (streaming) abort();
|
||||
createConversation();
|
||||
setSidebarOpen(false);
|
||||
setInput('');
|
||||
}, [createConversation, streaming, abort]);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="chat-page-root">
|
||||
{/* Header */}
|
||||
<header className="chat-page-header">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Mobile sidebar trigger */}
|
||||
<button
|
||||
className="chat-page-menu-btn md:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
>
|
||||
<PanelLeft size={18} />
|
||||
</button>
|
||||
{/* Desktop sidebar toggle */}
|
||||
<button
|
||||
className="chat-page-menu-btn hidden md:flex"
|
||||
onClick={() => setDesktopSidebarVisible(!desktopSidebarVisible)}
|
||||
aria-label={desktopSidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
|
||||
>
|
||||
{desktopSidebarVisible ? <PanelLeftClose size={18} /> : <PanelLeft size={18} />}
|
||||
</button>
|
||||
<a href="/" className="chat-page-logo">
|
||||
<Zap size={18} className="text-[var(--color-sb-accent)]" />
|
||||
<span>SpiceBook</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="chat-page-header-title">
|
||||
{activeConv?.title ?? 'Circuit Assistant'}
|
||||
</div>
|
||||
<div className="w-10" /> {/* Spacer for centering */}
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="chat-page-body">
|
||||
{/* Desktop sidebar */}
|
||||
{desktopSidebarVisible && (
|
||||
<aside className="chat-page-desktop-sidebar">
|
||||
<ChatSidebar onNewConversation={handleNewConversation} />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar (Sheet drawer) */}
|
||||
<Sheet open={sidebarOpen} onOpenChange={setSidebarOpen}>
|
||||
<SheetContent side="left" className="p-0 w-[300px]">
|
||||
<SheetTitle className="sr-only">Conversations</SheetTitle>
|
||||
<ChatSidebar onNewConversation={handleNewConversation} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Message area */}
|
||||
<main className="chat-page-main">
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
streaming={streaming}
|
||||
statusText={statusText}
|
||||
reasoningText={reasoningText}
|
||||
reasoningTime={reasoningTime}
|
||||
/>
|
||||
<ChatInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSend={handleSend}
|
||||
onAbort={abort}
|
||||
streaming={streaming}
|
||||
notebookTitle={selectedNotebookMeta?.title}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/chat/ChatSidebar.tsx
Normal file
111
frontend/src/components/chat/ChatSidebar.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plus, MessageSquare, Trash2, Search } from 'lucide-react';
|
||||
import { useChatStore } from '../../lib/chat-store';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Separator } from '../ui/separator';
|
||||
import NotebookPicker from './NotebookPicker';
|
||||
|
||||
interface ChatSidebarProps {
|
||||
onNewConversation: () => void;
|
||||
}
|
||||
|
||||
export default function ChatSidebar({ onNewConversation }: ChatSidebarProps) {
|
||||
const conversations = useChatStore((s) => s.conversations);
|
||||
const activeId = useChatStore((s) => s.activeConversationId);
|
||||
const setActive = useChatStore((s) => s.setActiveConversation);
|
||||
const deleteConv = useChatStore((s) => s.deleteConversation);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const pendingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const filtered = search.trim()
|
||||
? conversations.filter((c) =>
|
||||
c.title.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: conversations;
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (pendingDeleteId === id) {
|
||||
if (pendingTimer.current) clearTimeout(pendingTimer.current);
|
||||
setPendingDeleteId(null);
|
||||
deleteConv(id);
|
||||
} else {
|
||||
if (pendingTimer.current) clearTimeout(pendingTimer.current);
|
||||
setPendingDeleteId(id);
|
||||
pendingTimer.current = setTimeout(() => setPendingDeleteId(null), 3000);
|
||||
}
|
||||
},
|
||||
[pendingDeleteId, deleteConv],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pendingTimer.current) clearTimeout(pendingTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="chat-page-sidebar">
|
||||
{/* New conversation */}
|
||||
<div className="chat-page-sidebar-header">
|
||||
<button className="chat-page-new-btn" onClick={onNewConversation}>
|
||||
<Plus size={14} />
|
||||
<span>New conversation</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="chat-page-sidebar-search">
|
||||
<Search size={14} className="text-[var(--color-sb-muted)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search conversations…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="chat-page-sidebar-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conversation list */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="chat-page-conv-list">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-8 text-center text-sm text-[var(--color-sb-muted)]">
|
||||
{search ? 'No matching conversations' : 'No conversations yet'}
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`chat-page-conv-item ${c.id === activeId ? 'active' : ''}`}
|
||||
onClick={() => setActive(c.id)}
|
||||
>
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-sm">{c.title}</div>
|
||||
<div className="text-xs text-[var(--color-sb-muted)]">
|
||||
{c.messages.length} message{c.messages.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`chat-page-conv-delete ${pendingDeleteId === c.id ? 'confirm' : ''}`}
|
||||
onClick={(e) => handleDelete(c.id, e)}
|
||||
title={pendingDeleteId === c.id ? 'Click again to delete' : 'Delete'}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Notebook picker */}
|
||||
<NotebookPicker />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
306
frontend/src/components/chat/ChatWidget.tsx
Normal file
306
frontend/src/components/chat/ChatWidget.tsx
Normal file
@ -0,0 +1,306 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Zap,
|
||||
X,
|
||||
Plus,
|
||||
History,
|
||||
MessageSquare,
|
||||
Send,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useChatStore } from '../../lib/chat-store';
|
||||
import type { ChatMessage } from '../../lib/chat-store';
|
||||
import { renderMarkdown } from '../../lib/chat-render';
|
||||
import { useChatStream } from '../../lib/use-chat-stream';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import '../../styles/chat-widget.css';
|
||||
|
||||
// ── Components ──────────────────────────────────────────
|
||||
|
||||
function MessageBubble({ msg, isStreaming }: { msg: ChatMessage; isStreaming?: boolean }) {
|
||||
return (
|
||||
<div className={`chat-bubble ${msg.role}`}>
|
||||
{msg.role === 'assistant' ? (
|
||||
<>
|
||||
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(msg.text) }} />
|
||||
{isStreaming && <span className="chat-cursor" />}
|
||||
</>
|
||||
) : (
|
||||
msg.text
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryView({
|
||||
onBack,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const conversations = useChatStore((s) => s.conversations);
|
||||
const activeId = useChatStore((s) => s.activeConversationId);
|
||||
const setActive = useChatStore((s) => s.setActiveConversation);
|
||||
const deleteConv = useChatStore((s) => s.deleteConversation);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const pendingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (pendingDeleteId === id) {
|
||||
// Second click — confirm
|
||||
if (pendingTimer.current) clearTimeout(pendingTimer.current);
|
||||
setPendingDeleteId(null);
|
||||
deleteConv(id);
|
||||
} else {
|
||||
// First click — arm
|
||||
if (pendingTimer.current) clearTimeout(pendingTimer.current);
|
||||
setPendingDeleteId(id);
|
||||
pendingTimer.current = setTimeout(() => setPendingDeleteId(null), 3000);
|
||||
}
|
||||
},
|
||||
[pendingDeleteId, deleteConv],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pendingTimer.current) clearTimeout(pendingTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (conversations.length === 0) {
|
||||
return (
|
||||
<div className="chat-history-list">
|
||||
<div className="chat-empty">No conversations yet</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-history-list">
|
||||
{conversations.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={`chat-history-item ${c.id === activeId ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActive(c.id);
|
||||
onBack();
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
<span className="chat-history-title">{c.title}</span>
|
||||
<span className="chat-history-count">{c.messages.length}</span>
|
||||
<span
|
||||
className={`chat-history-delete ${pendingDeleteId === c.id ? 'confirm' : ''}`}
|
||||
onClick={(e) => handleDelete(c.id, e)}
|
||||
title={pendingDeleteId === c.id ? 'Click again to delete' : 'Delete conversation'}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Widget ─────────────────────────────────────────
|
||||
|
||||
export default function ChatWidget() {
|
||||
const panelOpen = useChatStore((s) => s.panelOpen);
|
||||
const togglePanel = useChatStore((s) => s.togglePanel);
|
||||
const closePanel = useChatStore((s) => s.closePanel);
|
||||
const createConversation = useChatStore((s) => s.createConversation);
|
||||
const getActiveConversation = useChatStore((s) => s.getActiveConversation);
|
||||
|
||||
const {
|
||||
sendMessage,
|
||||
abort,
|
||||
streaming,
|
||||
statusText,
|
||||
reasoningText,
|
||||
reasoningTime,
|
||||
} = useChatStream();
|
||||
|
||||
const [input, setInput] = useState('');
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const activeConv = getActiveConversation();
|
||||
const messages = activeConv?.messages ?? [];
|
||||
|
||||
// Auto-scroll on new messages — use direct scrollTop instead of scrollIntoView
|
||||
// because scrollIntoView walks up the DOM and scrolls ALL ancestors (including
|
||||
// the panel with overflow:hidden), which pushes the header off-screen.
|
||||
useEffect(() => {
|
||||
const container = messagesEndRef.current?.parentElement;
|
||||
if (container) {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
}, [messages.length, streaming]);
|
||||
|
||||
// Focus input when panel opens
|
||||
useEffect(() => {
|
||||
if (panelOpen && !showHistory) {
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [panelOpen, showHistory]);
|
||||
|
||||
// Keyboard: Escape to close
|
||||
useEffect(() => {
|
||||
function handleEscape(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && panelOpen) {
|
||||
closePanel();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [panelOpen, closePanel]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const question = input.trim();
|
||||
if (!question || streaming) return;
|
||||
setInput('');
|
||||
await sendMessage(question);
|
||||
}, [input, streaming, sendMessage]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
const handleNewConversation = useCallback(() => {
|
||||
if (streaming) {
|
||||
abort();
|
||||
}
|
||||
createConversation();
|
||||
setShowHistory(false);
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}, [createConversation, streaming, abort]);
|
||||
|
||||
const handlePopOut = useCallback(() => {
|
||||
window.open('/chat', '_blank');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Floating action button */}
|
||||
<button
|
||||
className={`chat-fab ${panelOpen ? 'active' : ''}`}
|
||||
onClick={togglePanel}
|
||||
aria-label="Toggle chat"
|
||||
title="Circuit assistant"
|
||||
>
|
||||
{panelOpen ? <X size={20} /> : <Zap size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Chat panel */}
|
||||
{panelOpen && (
|
||||
<div className="chat-panel">
|
||||
{/* Header */}
|
||||
<div className="chat-header">
|
||||
<Zap size={16} className="text-blue-500" />
|
||||
<span className="chat-header-title">
|
||||
{showHistory
|
||||
? 'Conversations'
|
||||
: activeConv?.title ?? 'Circuit Assistant'}
|
||||
</span>
|
||||
<button
|
||||
className="chat-header-btn"
|
||||
onClick={handlePopOut}
|
||||
title="Open full chat page"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="chat-header-btn"
|
||||
onClick={handleNewConversation}
|
||||
title="New conversation"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="chat-header-btn"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
title={showHistory ? 'Back to chat' : 'Conversation history'}
|
||||
>
|
||||
{showHistory ? <MessageSquare size={16} /> : <History size={16} />}
|
||||
</button>
|
||||
<button
|
||||
className="chat-header-btn"
|
||||
onClick={closePanel}
|
||||
title="Close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
{showHistory ? (
|
||||
<HistoryView onBack={() => setShowHistory(false)} />
|
||||
) : (
|
||||
<>
|
||||
<div className="chat-messages">
|
||||
{messages.length === 0 && !streaming && (
|
||||
<div className="chat-empty">
|
||||
Ask about circuits, netlists, or simulation results
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble
|
||||
key={`${msg.timestamp}-${i}`}
|
||||
msg={msg}
|
||||
isStreaming={
|
||||
streaming &&
|
||||
i === messages.length - 1 &&
|
||||
msg.role === 'assistant'
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{reasoningText && streaming && (
|
||||
<details className="chat-reasoning">
|
||||
<summary>Thinking{reasoningTime > 0 ? ` (${reasoningTime}s)` : '…'}</summary>
|
||||
<div className="chat-reasoning-body">{reasoningText}</div>
|
||||
</details>
|
||||
)}
|
||||
{statusText && (
|
||||
<div className="chat-status">{statusText}</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="chat-input-bar">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="chat-input"
|
||||
type="text"
|
||||
placeholder="Ask a circuit question…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={streaming}
|
||||
/>
|
||||
<button
|
||||
className="chat-send-btn"
|
||||
onClick={handleSend}
|
||||
disabled={streaming || !input.trim()}
|
||||
title="Send"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
97
frontend/src/components/chat/NotebookPicker.tsx
Normal file
97
frontend/src/components/chat/NotebookPicker.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { BookOpen, X } from 'lucide-react';
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '../ui/popover';
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../ui/command';
|
||||
import { listNotebooks } from '../../lib/api';
|
||||
import type { NotebookSummary } from '../../lib/types';
|
||||
import { useChatStore } from '../../lib/chat-store';
|
||||
|
||||
export default function NotebookPicker() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [notebooks, setNotebooks] = useState<NotebookSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const selectedId = useChatStore((s) => s.selectedNotebookId);
|
||||
const selectedMeta = useChatStore((s) => s.selectedNotebookMeta);
|
||||
const setSelected = useChatStore((s) => s.setSelectedNotebook);
|
||||
const clearSelected = useChatStore((s) => s.clearSelectedNotebook);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
listNotebooks()
|
||||
.then(setNotebooks)
|
||||
.catch(() => setNotebooks([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(nb: NotebookSummary) => {
|
||||
setSelected(nb.id, { title: nb.title, engine: nb.engine });
|
||||
setOpen(false);
|
||||
},
|
||||
[setSelected],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
clearSelected();
|
||||
},
|
||||
[clearSelected],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="chat-page-notebook-picker">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button className="chat-page-notebook-btn">
|
||||
<BookOpen size={14} />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{selectedMeta ? selectedMeta.title : 'Select notebook…'}
|
||||
</span>
|
||||
{selectedId && (
|
||||
<span
|
||||
className="chat-page-notebook-clear"
|
||||
onClick={handleClear}
|
||||
title="Clear selection"
|
||||
>
|
||||
<X size={12} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start" side="top">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search notebooks…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{loading ? 'Loading…' : 'No notebooks found'}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{notebooks.map((nb) => (
|
||||
<CommandItem
|
||||
key={nb.id}
|
||||
value={`${nb.title} ${nb.id}`}
|
||||
onSelect={() => handleSelect(nb)}
|
||||
>
|
||||
<BookOpen size={14} className="shrink-0 text-[var(--color-sb-muted)]" />
|
||||
<div className="flex flex-col gap-0.5 overflow-hidden">
|
||||
<span className="truncate text-sm">{nb.title}</span>
|
||||
<span className="text-xs text-[var(--color-sb-muted)]">
|
||||
{nb.engine} · {nb.cell_count} cell{nb.cell_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{nb.id === selectedId && (
|
||||
<span className="ml-auto text-[var(--color-sb-accent)] text-xs">✓</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/ui/command.tsx
Normal file
111
frontend/src/components/ui/command.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-lg bg-[var(--color-sb-surface)] text-[var(--color-sb-text)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b border-[var(--color-sb-border)] px-3" cmdk-input-wrapper="">
|
||||
<Search size={14} className="mr-2 shrink-0 text-[var(--color-sb-muted)]" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md bg-transparent py-2 text-sm outline-none placeholder:text-[var(--color-sb-muted)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm text-[var(--color-sb-muted)]" {...props} />
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-[var(--color-sb-text)] [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-[var(--color-sb-muted)]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none',
|
||||
'data-[selected=true]:bg-[var(--color-sb-cell)] data-[selected=true]:text-[var(--color-sb-text-bright)]',
|
||||
'data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-[var(--color-sb-border)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandSeparator,
|
||||
};
|
||||
32
frontend/src/components/ui/popover.tsx
Normal file
32
frontend/src/components/ui/popover.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-lg border border-[var(--color-sb-border)] bg-[var(--color-sb-surface)] p-4 shadow-md outline-none',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
43
frontend/src/components/ui/scroll-area.tsx
Normal file
43
frontend/src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = 'vertical', ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none select-none transition-colors',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-[var(--color-sb-border)] hover:bg-[var(--color-sb-muted)]" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
23
frontend/src/components/ui/separator.tsx
Normal file
23
frontend/src/components/ui/separator.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-[var(--color-sb-border)]',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
88
frontend/src/components/ui/sheet.tsx
Normal file
88
frontend/src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
const Sheet = DialogPrimitive.Root;
|
||||
const SheetTrigger = DialogPrimitive.Trigger;
|
||||
const SheetClose = DialogPrimitive.Close;
|
||||
const SheetPortal = DialogPrimitive.Portal;
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = 'left', className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed z-50 flex flex-col gap-4 bg-[var(--color-sb-bg)] shadow-lg transition ease-in-out',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-300 data-[state=closed]:duration-200',
|
||||
side === 'left' &&
|
||||
'inset-y-0 left-0 h-full w-3/4 max-w-[320px] border-r border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left',
|
||||
side === 'right' &&
|
||||
'inset-y-0 right-0 h-full w-3/4 max-w-[320px] border-l border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right',
|
||||
side === 'top' &&
|
||||
'inset-x-0 top-0 border-b border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
side === 'bottom' &&
|
||||
'inset-x-0 bottom-0 border-t border-[var(--color-sb-border)] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 text-[var(--color-sb-muted)] hover:text-[var(--color-sb-text)]">
|
||||
<X size={16} />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</SheetPortal>
|
||||
));
|
||||
SheetContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col gap-2 px-4 pt-4', className)} {...props} />
|
||||
);
|
||||
SheetHeader.displayName = 'SheetHeader';
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold text-[var(--color-sb-text-bright)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SheetTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
};
|
||||
31
frontend/src/components/ui/tooltip.tsx
Normal file
31
frontend/src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-[var(--color-sb-cell)] px-3 py-1.5 text-xs text-[var(--color-sb-text)]',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
67
frontend/src/layouts/ChatLayout.astro
Normal file
67
frontend/src/layouts/ChatLayout.astro
Normal file
@ -0,0 +1,67 @@
|
||||
---
|
||||
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>
|
||||
@ -2,6 +2,7 @@
|
||||
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;
|
||||
@ -51,6 +52,7 @@ 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) {
|
||||
|
||||
@ -7,7 +7,7 @@ import type {
|
||||
CreateNotebookResponse,
|
||||
} from './types';
|
||||
|
||||
const API_BASE = (() => {
|
||||
export 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)
|
||||
|
||||
86
frontend/src/lib/chat-api.ts
Normal file
86
frontend/src/lib/chat-api.ts
Normal file
@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
68
frontend/src/lib/chat-render.ts
Normal file
68
frontend/src/lib/chat-render.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 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'],
|
||||
});
|
||||
}
|
||||
189
frontend/src/lib/chat-store.ts
Normal file
189
frontend/src/lib/chat-store.ts
Normal file
@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
176
frontend/src/lib/use-chat-stream.ts
Normal file
176
frontend/src/lib/use-chat-stream.ts
Normal file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
8
frontend/src/pages/chat.astro
Normal file
8
frontend/src/pages/chat.astro
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
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>
|
||||
@ -72,7 +72,7 @@ try {
|
||||
<PipelineStrip />
|
||||
</section>
|
||||
|
||||
<!-- Resistor Color Code Reference link -->
|
||||
<!-- Reference links -->
|
||||
<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,6 +103,35 @@ 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>
|
||||
|
||||
|
||||
657
frontend/src/styles/chat-page.css
Normal file
657
frontend/src/styles/chat-page.css
Normal file
@ -0,0 +1,657 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
514
frontend/src/styles/chat-widget.css
Normal file
514
frontend/src/styles/chat-widget.css
Normal file
@ -0,0 +1,514 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@ -32,6 +32,33 @@
|
||||
--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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user