feat: voice-agent MVP — LXC web frontend + Mac AI worker

LXC-Frontend (FastAPI + HTML/JS):
- Audio-Upload (MP3/WAV/M4A/MP4/OGG/FLAC, max. 500 MB)
- SQLite Job-Store, BackgroundTask-Pipeline
- Job-Liste mit Live-Status, Downloads (DOCX + JSON)
- Mac-Health-Indicator im UI

Mac-Worker (FastAPI):
- /api/transcribe (lightning-whisper-mlx | faster-whisper | mock)
- /api/summarize + /api/protocol via Ollama (llama3.1:8b)
- /api/export/docx via python-docx

Deploy:
- systemd-Service, Nginx Reverse-Proxy
- deploy/install.sh: idempotentes LXC-Setup

Doku: README.md, lxc-frontend/README.md, mac-worker/README.md
This commit is contained in:
root
2026-05-13 15:33:53 +00:00
parent 32ab12014f
commit 5321c8602e
35 changed files with 1820 additions and 4 deletions
+17
View File
@@ -0,0 +1,17 @@
# LXC-Frontend Konfiguration
APP_NAME="Voice-Agent"
APP_HOST=0.0.0.0
APP_PORT=8000
# Verzeichnisse (werden bei Start angelegt)
UPLOAD_DIR=/var/lib/voice-agent/uploads
RESULT_DIR=/var/lib/voice-agent/results
DB_URL=sqlite:////var/lib/voice-agent/voice-agent.db
# Mac-Worker API (im LAN erreichbar)
MAC_API_URL=http://192.168.85.10:8080
MAC_API_TIMEOUT=1800
# Limits
MAX_UPLOAD_MB=500
ALLOWED_EXTS=mp3,wav,m4a,mp4,ogg,flac
+44
View File
@@ -0,0 +1,44 @@
# Voice-Agent — LXC-Frontend
FastAPI + statisches HTML/JS. Läuft im LXC-Container, stellt die Weboberfläche, verwaltet Jobs (SQLite) und ruft den Mac-Worker per HTTP an. Macht selbst **keine** AI-Verarbeitung.
## Lokal starten (Entwicklung)
```bash
cd lxc-frontend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# Für lokalen Test ohne Mac kann der Mac-Worker mit WHISPER_ENGINE=mock laufen
uvicorn app.main:app --reload --port 8000
```
Öffnen: http://localhost:8000
## Auf LXC deployen
`deploy/install.sh` macht alles: Repo klonen, venv anlegen, systemd-Service und Nginx einrichten. Siehe Haupt-`README.md`.
## API
| Methode | Pfad | Zweck |
|---|---|---|
| GET | `/` | Web-UI |
| GET | `/health` | LXC-Health |
| GET | `/api/mac/health` | Mac-Worker-Erreichbarkeit |
| POST | `/api/jobs` | multipart: `audio`, `title` → Job anlegen |
| GET | `/api/jobs` | Liste aller Jobs |
| GET | `/api/jobs/{id}` | Job-Detail |
| GET | `/api/jobs/{id}/download/{kind}` | `kind` ∈ `docx`, `transcript`, `summary`, `protocol` |
## Datenfluss
```
Upload → SQLite-Job → BackgroundTask
→ Mac: /api/transcribe (multipart)
→ Mac: /api/summarize (JSON)
→ Mac: /api/protocol (JSON)
→ Mac: /api/export/docx (JSON) → DOCX
→ Ergebnisse landen in /var/lib/voice-agent/results/{job_id}/
```
View File
View File
+105
View File
@@ -0,0 +1,105 @@
import secrets
from datetime import datetime, timezone
from pathlib import Path
import aiofiles
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session, select
from app.core.config import settings
from app.core.db import get_session
from app.models.job import Job, JobStatus
from app.services.pipeline import run_pipeline
router = APIRouter(prefix="/api/jobs", tags=["jobs"])
MAX_BYTES = lambda: settings.max_upload_mb * 1024 * 1024
@router.post("")
async def create_job(
background: BackgroundTasks,
audio: UploadFile = File(...),
title: str = Form(""),
session: Session = Depends(get_session),
):
if not audio.filename:
raise HTTPException(400, "Dateiname fehlt")
ext = Path(audio.filename).suffix.lower().lstrip(".")
if ext not in settings.allowed_ext_set:
raise HTTPException(400, f"Format '{ext}' nicht erlaubt. Erlaubt: {sorted(settings.allowed_ext_set)}")
safe = secrets.token_hex(8) + "." + ext
dest = settings.upload_dir / safe
size = 0
limit = MAX_BYTES()
async with aiofiles.open(dest, "wb") as f:
while chunk := await audio.read(1024 * 1024):
size += len(chunk)
if size > limit:
await f.close()
dest.unlink(missing_ok=True)
raise HTTPException(413, f"Datei zu groß (max. {settings.max_upload_mb} MB)")
await f.write(chunk)
job = Job(filename=safe, original_name=audio.filename, title=title.strip())
session.add(job)
session.commit()
session.refresh(job)
background.add_task(run_pipeline, job.id)
return _job_dict(job)
@router.get("")
def list_jobs(session: Session = Depends(get_session)):
jobs = session.exec(select(Job).order_by(Job.created_at.desc())).all()
return [_job_dict(j) for j in jobs]
@router.get("/{job_id}")
def get_job(job_id: int, session: Session = Depends(get_session)):
job = session.get(Job, job_id)
if not job:
raise HTTPException(404, "Job nicht gefunden")
return _job_dict(job)
@router.get("/{job_id}/download/{kind}")
def download(job_id: int, kind: str, session: Session = Depends(get_session)):
job = session.get(Job, job_id)
if not job:
raise HTTPException(404, "Job nicht gefunden")
mapping = {
"docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"),
"transcript": (job.transcript_path, "application/json", "transcript.json"),
"summary": (job.summary_path, "application/json", "summary.json"),
"protocol": (job.protocol_path, "application/json", "protocol.json"),
}
if kind not in mapping:
raise HTTPException(400, "Unbekannter Download-Typ")
path, media, name = mapping[kind]
if not path or not Path(path).exists():
raise HTTPException(404, "Datei noch nicht verfügbar")
base = (job.title or Path(job.original_name).stem).replace("/", "_")
return FileResponse(path, media_type=media, filename=f"{base}-{name}")
def _job_dict(j: Job) -> dict:
return {
"id": j.id,
"title": j.title,
"original_name": j.original_name,
"status": j.status,
"progress": j.progress,
"error": j.error,
"created_at": j.created_at.isoformat(),
"updated_at": j.updated_at.isoformat(),
"has": {
"transcript": bool(j.transcript_path),
"summary": bool(j.summary_path),
"protocol": bool(j.protocol_path),
"docx": bool(j.docx_path),
},
}
View File
+29
View File
@@ -0,0 +1,29 @@
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_name: str = "Voice-Agent"
app_host: str = "0.0.0.0"
app_port: int = 8000
upload_dir: Path = Path("/var/lib/voice-agent/uploads")
result_dir: Path = Path("/var/lib/voice-agent/results")
db_url: str = "sqlite:////var/lib/voice-agent/voice-agent.db"
mac_api_url: str = "http://192.168.85.10:8080"
mac_api_timeout: int = 1800
max_upload_mb: int = 500
allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac"
@property
def allowed_ext_set(self) -> set[str]:
return {e.strip().lower().lstrip(".") for e in self.allowed_exts.split(",") if e.strip()}
settings = Settings()
settings.upload_dir.mkdir(parents=True, exist_ok=True)
settings.result_dir.mkdir(parents=True, exist_ok=True)
+14
View File
@@ -0,0 +1,14 @@
from sqlmodel import SQLModel, create_engine, Session
from .config import settings
engine = create_engine(settings.db_url, connect_args={"check_same_thread": False})
def init_db() -> None:
from app.models.job import Job # noqa: F401
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session
+48
View File
@@ -0,0 +1,48 @@
from pathlib import Path
import httpx
from .config import settings
class MacClient:
def __init__(self, base_url: str | None = None, timeout: int | None = None):
self.base_url = (base_url or settings.mac_api_url).rstrip("/")
self.timeout = timeout or settings.mac_api_timeout
async def health(self) -> dict:
async with httpx.AsyncClient(timeout=10) as c:
r = await c.get(f"{self.base_url}/health")
r.raise_for_status()
return r.json()
async def transcribe(self, audio_path: Path, language: str = "de") -> dict:
async with httpx.AsyncClient(timeout=self.timeout) as c:
with audio_path.open("rb") as f:
files = {"audio": (audio_path.name, f, "application/octet-stream")}
data = {"language": language}
r = await c.post(f"{self.base_url}/api/transcribe", files=files, data=data)
r.raise_for_status()
return r.json()
async def summarize(self, transcript: str, title: str = "") -> dict:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(
f"{self.base_url}/api/summarize",
json={"transcript": transcript, "title": title},
)
r.raise_for_status()
return r.json()
async def protocol(self, transcript: str, summary: dict, title: str = "") -> dict:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(
f"{self.base_url}/api/protocol",
json={"transcript": transcript, "summary": summary, "title": title},
)
r.raise_for_status()
return r.json()
async def export_docx(self, protocol: dict) -> bytes:
async with httpx.AsyncClient(timeout=self.timeout) as c:
r = await c.post(f"{self.base_url}/api/export/docx", json=protocol)
r.raise_for_status()
return r.content
+51
View File
@@ -0,0 +1,51 @@
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from app.api.jobs import router as jobs_router
from app.core.config import settings
from app.core.db import init_db
from app.core.mac_client import MacClient
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
log = logging.getLogger("voice-agent")
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
log.info("DB initialized at %s", settings.db_url)
log.info("Upload dir: %s", settings.upload_dir)
log.info("Result dir: %s", settings.result_dir)
log.info("Mac API: %s", settings.mac_api_url)
yield
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.include_router(jobs_router)
STATIC_DIR = Path(__file__).resolve().parent / "static"
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.get("/")
def index():
return FileResponse(STATIC_DIR / "index.html")
@app.get("/health")
def health():
return {"status": "ok", "service": settings.app_name}
@app.get("/api/mac/health")
async def mac_health():
try:
data = await MacClient().health()
return {"reachable": True, "mac": data}
except Exception as e: # noqa: BLE001
return JSONResponse(status_code=502, content={"reachable": False, "error": str(e)})
View File
+29
View File
@@ -0,0 +1,29 @@
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import SQLModel, Field
class JobStatus:
QUEUED = "queued"
TRANSCRIBING = "transcribing"
SUMMARIZING = "summarizing"
PROTOCOLLING = "protocolling"
EXPORTING = "exporting"
DONE = "done"
FAILED = "failed"
class Job(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
filename: str
original_name: str
title: str = ""
status: str = JobStatus.QUEUED
progress: int = 0
error: str = ""
transcript_path: str = ""
summary_path: str = ""
protocol_path: str = ""
docx_path: str = ""
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+76
View File
@@ -0,0 +1,76 @@
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from sqlmodel import Session
from app.core.config import settings
from app.core.db import engine
from app.core.mac_client import MacClient
from app.models.job import Job, JobStatus
log = logging.getLogger("voice-agent.pipeline")
def _update(job_id: int, **fields) -> None:
with Session(engine) as s:
job = s.get(Job, job_id)
if not job:
return
for k, v in fields.items():
setattr(job, k, v)
job.updated_at = datetime.now(timezone.utc)
s.add(job)
s.commit()
async def run_pipeline(job_id: int) -> None:
"""Run the full pipeline: transcribe → summarize → protocol → DOCX."""
client = MacClient()
with Session(engine) as s:
job = s.get(Job, job_id)
if not job:
log.error("Job %s not found", job_id)
return
audio_path = settings.upload_dir / job.filename
title = job.title or audio_path.stem
job_dir = settings.result_dir / str(job_id)
job_dir.mkdir(parents=True, exist_ok=True)
try:
_update(job_id, status=JobStatus.TRANSCRIBING, progress=10)
log.info("[job %s] transcribe", job_id)
tx = await client.transcribe(audio_path, language="de")
transcript_path = job_dir / "transcript.json"
transcript_path.write_text(json.dumps(tx, ensure_ascii=False, indent=2), encoding="utf-8")
_update(job_id, transcript_path=str(transcript_path), progress=45)
transcript_text = tx.get("text") or "\n".join(s.get("text", "") for s in tx.get("segments", []))
_update(job_id, status=JobStatus.SUMMARIZING, progress=55)
log.info("[job %s] summarize", job_id)
summary = await client.summarize(transcript_text, title=title)
summary_path = job_dir / "summary.json"
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
_update(job_id, summary_path=str(summary_path), progress=70)
_update(job_id, status=JobStatus.PROTOCOLLING, progress=75)
log.info("[job %s] protocol", job_id)
protocol = await client.protocol(transcript_text, summary, title=title)
protocol_path = job_dir / "protocol.json"
protocol_path.write_text(json.dumps(protocol, ensure_ascii=False, indent=2), encoding="utf-8")
_update(job_id, protocol_path=str(protocol_path), progress=85)
_update(job_id, status=JobStatus.EXPORTING, progress=90)
log.info("[job %s] export docx", job_id)
docx_bytes = await client.export_docx(protocol)
docx_path = job_dir / "protocol.docx"
docx_path.write_bytes(docx_bytes)
_update(job_id, docx_path=str(docx_path), status=JobStatus.DONE, progress=100)
log.info("[job %s] done", job_id)
except Exception as e: # noqa: BLE001
log.exception("[job %s] failed", job_id)
_update(job_id, status=JobStatus.FAILED, error=f"{type(e).__name__}: {e}")
+125
View File
@@ -0,0 +1,125 @@
const form = document.getElementById("upload-form");
const titleInput = document.getElementById("title");
const audioInput = document.getElementById("audio");
const submitBtn = document.getElementById("submit-btn");
const uploadProgress = document.getElementById("upload-progress");
const uploadBar = uploadProgress.querySelector(".bar-fill");
const uploadMsg = document.getElementById("upload-msg");
const jobsBody = document.getElementById("jobs-body");
const macStatus = document.getElementById("mac-status");
async function checkMac() {
try {
const r = await fetch("/api/mac/health");
const j = await r.json();
if (r.ok && j.reachable) {
macStatus.textContent = "Mac-Backend: online";
macStatus.classList.add("ok");
macStatus.classList.remove("err");
} else {
macStatus.textContent = "Mac-Backend: nicht erreichbar";
macStatus.classList.add("err");
macStatus.classList.remove("ok");
}
} catch {
macStatus.textContent = "Mac-Backend: Fehler";
macStatus.classList.add("err");
}
}
function fmtDate(s) {
const d = new Date(s);
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" });
}
function row(job) {
const tr = document.createElement("tr");
const title = job.title || job.original_name;
const dl = (kind, label) => {
const a = document.createElement("a");
a.textContent = label;
a.href = `/api/jobs/${job.id}/download/${kind}`;
if (!job.has[kind]) a.classList.add("disabled");
return a;
};
const dlCell = document.createElement("td");
dlCell.className = "dl";
["docx", "protocol", "summary", "transcript"].forEach((k) =>
dlCell.appendChild(dl(k, k === "docx" ? "DOCX" : k.charAt(0).toUpperCase() + k.slice(1) + " (JSON)"))
);
tr.innerHTML = `
<td>#${job.id}</td>
<td>${title}<br><small class="muted">${job.original_name}</small></td>
<td><span class="status-pill status-${job.status}">${job.status}${job.error ? " — " + job.error : ""}</span></td>
<td><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td>
<td><small class="muted">${fmtDate(job.updated_at)}</small></td>
`;
tr.appendChild(dlCell);
return tr;
}
async function loadJobs() {
try {
const r = await fetch("/api/jobs");
const jobs = await r.json();
jobsBody.innerHTML = "";
if (!jobs.length) {
jobsBody.innerHTML = '<tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr>';
return;
}
jobs.forEach((j) => jobsBody.appendChild(row(j)));
} catch (e) {
console.error(e);
}
}
form.addEventListener("submit", (ev) => {
ev.preventDefault();
if (!audioInput.files.length) return;
const fd = new FormData();
fd.append("audio", audioInput.files[0]);
fd.append("title", titleInput.value);
submitBtn.disabled = true;
uploadProgress.classList.remove("hidden");
uploadBar.style.width = "0%";
uploadMsg.textContent = "";
uploadMsg.className = "msg";
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/jobs");
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const p = Math.round((e.loaded / e.total) * 100);
uploadBar.style.width = p + "%";
}
};
xhr.onload = () => {
submitBtn.disabled = false;
uploadProgress.classList.add("hidden");
if (xhr.status >= 200 && xhr.status < 300) {
const j = JSON.parse(xhr.responseText);
uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`;
uploadMsg.className = "msg ok";
form.reset();
loadJobs();
} else {
let err = "Upload fehlgeschlagen";
try { err = JSON.parse(xhr.responseText).detail || err; } catch {}
uploadMsg.textContent = err;
uploadMsg.className = "msg err";
}
};
xhr.onerror = () => {
submitBtn.disabled = false;
uploadProgress.classList.add("hidden");
uploadMsg.textContent = "Netzwerkfehler beim Upload";
uploadMsg.className = "msg err";
};
xhr.send(fd);
});
checkMac();
loadJobs();
setInterval(loadJobs, 3000);
setInterval(checkMac, 15000);
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Voice-Agent — Transkription</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header>
<h1>Voice-Agent</h1>
<p class="sub">Sitzungsaufnahmen automatisch transkribieren und protokollieren</p>
<div id="mac-status" class="badge">Mac-Backend: prüfe …</div>
</header>
<main>
<section class="card">
<h2>Neue Aufnahme hochladen</h2>
<form id="upload-form">
<label>
Sitzungstitel (optional)
<input type="text" name="title" id="title" placeholder="z. B. Monatsbesprechung 13.05.2026" />
</label>
<label>
Audiodatei (MP3, WAV, M4A, MP4, OGG, FLAC)
<input type="file" name="audio" id="audio" accept=".mp3,.wav,.m4a,.mp4,.ogg,.flac" required />
</label>
<button type="submit" id="submit-btn">Hochladen &amp; verarbeiten</button>
</form>
<div id="upload-progress" class="hidden">
<div class="bar"><div class="bar-fill"></div></div>
<small class="muted">Upload läuft …</small>
</div>
<p id="upload-msg" class="msg"></p>
</section>
<section class="card">
<h2>Jobs</h2>
<table id="jobs-table">
<thead>
<tr>
<th>#</th><th>Titel / Datei</th><th>Status</th><th>Fortschritt</th><th>Aktualisiert</th><th>Downloads</th>
</tr>
</thead>
<tbody id="jobs-body">
<tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr>
</tbody>
</table>
</section>
</main>
<footer>
<small class="muted">DSGVO-konform, alles lokal verarbeitet.</small>
</footer>
<script src="/static/app.js"></script>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
:root {
--bg: #0f1115;
--card: #181c24;
--border: #262b36;
--text: #e6e8eb;
--muted: #8b93a3;
--accent: #4f8cff;
--accent-hover: #3a78f0;
--ok: #2ecc71;
--warn: #f1c40f;
--err: #e74c3c;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
}
header {
padding: 32px 24px 16px;
text-align: center;
border-bottom: 1px solid var(--border);
}
header h1 { margin: 0 0 4px; font-size: 28px; letter-spacing: .3px; }
.sub { color: var(--muted); margin: 0 0 12px; }
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
font-size: 12px;
color: var(--muted);
}
.badge.ok { color: var(--ok); border-color: rgba(46,204,113,.4); }
.badge.err { color: var(--err); border-color: rgba(231,76,60,.4); }
main {
max-width: 1000px;
margin: 24px auto;
padding: 0 16px;
display: grid;
gap: 20px;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 22px;
}
.card h2 { margin-top: 0; font-size: 18px; }
form { display: grid; gap: 12px; }
label { display: grid; gap: 6px; font-size: 14px; color: var(--muted); }
input[type=text], input[type=file] {
background: #0f1219;
border: 1px solid var(--border);
color: var(--text);
padding: 10px 12px;
border-radius: 8px;
font-size: 14px;
}
button {
background: var(--accent);
color: white;
border: 0;
padding: 10px 16px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
justify-self: start;
}
button:hover { background: var(--accent-hover); }
button:disabled { opacity: .6; cursor: not-allowed; }
.hidden { display: none; }
.msg { min-height: 1em; margin: 8px 0 0; font-size: 13px; }
.msg.ok { color: var(--ok); }
.msg.err { color: var(--err); }
.muted { color: var(--muted); }
.bar {
width: 100%;
height: 8px;
background: #0f1219;
border-radius: 4px;
overflow: hidden;
margin-top: 6px;
}
.bar-fill {
height: 100%;
width: 0%;
background: var(--accent);
transition: width .25s ease;
}
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
.status-pill {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid var(--border);
}
.status-queued { color: var(--muted); }
.status-transcribing, .status-summarizing, .status-protocolling, .status-exporting { color: var(--warn); border-color: rgba(241,196,15,.4); }
.status-done { color: var(--ok); border-color: rgba(46,204,113,.4); }
.status-failed { color: var(--err); border-color: rgba(231,76,60,.4); }
.dl a {
color: var(--accent);
text-decoration: none;
margin-right: 10px;
font-size: 13px;
}
.dl a:hover { text-decoration: underline; }
.dl .disabled { color: var(--muted); pointer-events: none; }
footer { text-align: center; padding: 24px 16px 32px; }
+8
View File
@@ -0,0 +1,8 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
python-multipart==0.0.18
sqlmodel==0.0.22
httpx==0.28.1
pydantic-settings==2.7.0
jinja2==3.1.4
aiofiles==24.1.0