Compare commits

..

No commits in common. "f9bef7c30e83abd69f9d64167ffe131f7e15e6f7" and "b9e56d08251e837b2a3638bfac0b8d7940aeee3e" have entirely different histories.

10 changed files with 72 additions and 497 deletions

View file

@ -1,11 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-only
# Nightjar web app - runs the Plaud-style UI + API. AI is done by your configured
# providers (default: Groq), so this image stays slim (no local ML deps).
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir \
"fastapi>=0.111" "uvicorn[standard]>=0.30" "pydantic>=2.7" \
"pydantic-settings>=2.3" "python-multipart>=0.0.9" "httpx>=0.27" "anthropic>=0.40"
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View file

@ -1,60 +1,48 @@
# Nightjar server + web app # OpenScribe server
A self-hosted FastAPI app with a **Plaud-style web interface**: upload or record audio in Self-hosted FastAPI server: ingests recordings, transcribes them (faster-whisper),
the browser, watch it transcribe and summarise, browse a library, play back, and export. summarises them (Ollama), and serves the open API with exports. Everything runs on
The AI is done by whichever providers you configure, so you own the whole pipeline. hardware you own.
## Try it in two minutes > Status: M0 scaffold. The API shape is live and browsable at `/docs` with in-memory
> stubs. Transcription lands in M5, summaries in M6, real storage/DB alongside.
You need one AI key. The default config uses **Groq** (free tier, fast Whisper + LLM) for ## Run (dev)
both transcription and summaries.
```bash ```bash
cd server cd server
python -m venv .venv python -m venv .venv
. .venv/bin/activate # Windows: .venv\Scripts\activate . .venv/Scripts/activate # Windows; or: . .venv/bin/activate
pip install fastapi "uvicorn[standard]" pydantic pydantic-settings python-multipart httpx anthropic pip install -r requirements.txt
cp .env.example .env # then paste your Groq key into .env (both API_KEY lines) cp .env.example .env # edit as needed
uvicorn app.main:app --reload uvicorn app.main:app --reload
``` ```
Open **http://localhost:8000** and press Record (or Upload an audio file). It will show - API docs (Swagger UI): http://localhost:8000/docs
`transcribing… → summarising… → done`, then the transcript, a summary with key points and - Health: http://localhost:8000/health
action items, an audio player, and export buttons (TXT / Markdown / SRT / VTT / JSON).
Confirm your providers loaded at **http://localhost:8000/health**. ## Self-hosted dependencies
### Or with Docker For the AI features (M5/M6) you run, on your own kit:
```bash - **MinIO** (or WebDAV / NAS) for object storage - `OPENSCRIBE_STORAGE_BACKEND=s3`.
cd server - **Ollama** for summaries - `ollama serve` and `ollama pull llama3.1`.
docker build -t nightjar . - **faster-whisper** downloads its model on first use; CPU works, CUDA is faster.
docker run -p 8000:8000 --env-file .env -v "$PWD/media:/app/media" nightjar
```
## Choosing a different AI None of these are required for plain recording and transfer; they add transcription and
summaries.
Everything is set in `.env` (see `.env.example` and `docs/ai-providers.md`): OpenAI or any ## Layout
OpenAI-compatible endpoint, Anthropic Claude, or fully local (Ollama + faster-whisper for a
"nothing leaves my machine" setup). Change the vars, restart, done.
## What's inside
``` ```
app/main.py FastAPI: upload/list/detail/audio/export + serves the web UI app/main.py FastAPI app + routes (mirrors ../api/openapi.yaml)
app/pipeline.py audio -> transcribe -> summarise (background task) app/config.py Settings from env / .env
app/store.py recordings in SQLite, audio on disk (demo storage) app/models.py Pydantic models (kept in sync with the OpenAPI schemas)
app/providers/ the pluggable transcription + LLM providers requirements.txt
app/web/ the Plaud-style single-page UI (no build step) .env.example
``` ```
This is the demo/single-node app. The hosted multi-tenant service (metering, billing, ## API
object storage, Postgres, the Private-tier GPU node) is described in
`../docs/hosted-service.md` and `../docs/infrastructure.md`.
## Notes The contract is `../api/openapi.yaml`. The device implements the LAN "device" paths; this
server implements ingest, transcript, summary and export.
- Recordings and the SQLite index live under `NIGHTJAR_LOCAL_MEDIA_DIR` (default `./media`).
- Browser recording produces WebM/Opus, which Groq and OpenAI accept directly - no ffmpeg
needed for the demo.
- The API contract for the device/server is `../api/openapi.yaml`.

View file

@ -10,7 +10,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="NIGHTJAR_", env_file=".env") model_config = SettingsConfigDict(env_prefix="OPENSCRIBE_", env_file=".env")
# API # API
api_token: str = "change-me" # bearer token for write endpoints api_token: str = "change-me" # bearer token for write endpoints

View file

@ -1,136 +1,77 @@
# SPDX-License-Identifier: GPL-3.0-only # SPDX-License-Identifier: GPL-3.0-only
"""Nightjar web app - a Plaud-style interface plus the JSON API. """OpenScribe server - FastAPI app.
Upload or record audio, watch it transcribe and summarise, browse a library, play back and M0 scaffold: wires the routes from api/openapi.yaml with in-memory stubs so the API shape
export. The heavy AI is done by whichever providers you configure (see .env.example); is real and browsable at /docs. The AI pipeline (faster-whisper transcription in M5,
point it at a free Groq key and it works end to end. Ollama summaries in M6) and real storage/DB replace the stubs in later milestones.
""" """
from __future__ import annotations from __future__ import annotations
import json from fastapi import FastAPI, HTTPException
import os
from contextlib import asynccontextmanager
from fastapi import BackgroundTasks, FastAPI, HTTPException, UploadFile
from fastapi.responses import FileResponse, PlainTextResponse, Response
from fastapi.staticfiles import StaticFiles
from . import store
from .config import settings from .config import settings
from .pipeline import process from .models import Recording, RecordingPage, Summary, Transcript
WEB_DIR = os.path.join(os.path.dirname(__file__), "web") app = FastAPI(
title="OpenScribe API",
version="0.1.0",
description="Self-hosted AI voice recorder server. See api/openapi.yaml.",
)
# In-memory store stands in for the DB + object storage until M5.
@asynccontextmanager _recordings: dict[str, Recording] = {}
async def lifespan(app: FastAPI):
os.makedirs(settings.local_media_dir, exist_ok=True)
store.init()
yield
app = FastAPI(title="Nightjar", version="0.1.0", lifespan=lifespan)
@app.get("/health") @app.get("/health")
def health() -> dict: def health() -> dict:
# Report the resolved provider names (never secrets) so operators can confirm config.
from .providers import build_summariser, build_transcriber from .providers import build_summariser, build_transcriber
try: try:
summariser = build_summariser().name summariser = build_summariser().name
except Exception as exc: except Exception as exc: # e.g. anthropic dep not installed
summariser = f"unavailable ({type(exc).__name__})" summariser = f"unavailable ({type(exc).__name__})"
return { return {
"status": "ok", "status": "ok",
"storage_backend": settings.storage_backend,
"transcription_provider": settings.transcription_provider,
"transcriber": build_transcriber().name, "transcriber": build_transcriber().name,
"llm_provider": settings.llm_provider,
"summariser": summariser, "summariser": summariser,
} }
@app.post("/api/recordings") @app.get("/api/v1/recordings", response_model=RecordingPage, tags=["recordings"])
async def create_recording( def list_recordings(limit: int = 50) -> RecordingPage:
file: UploadFile, background: BackgroundTasks, title: str | None = None return RecordingPage(items=list(_recordings.values())[:limit], next_cursor=None)
) -> dict:
rec_id = store.create(
title=title or (file.filename or "Recording"),
filename=file.filename or "audio",
mime=file.content_type or "application/octet-stream",
)
with open(store.audio_path(rec_id), "wb") as f:
while chunk := await file.read(1 << 20):
f.write(chunk)
background.add_task(process, rec_id) # transcribe + summarise off the request path
return store.get(rec_id)
@app.get("/api/recordings") @app.get("/api/v1/recordings/{rec_id}", response_model=Recording, tags=["recordings"])
def list_recordings() -> list[dict]: def get_recording(rec_id: str) -> Recording:
return store.list_all() rec = _recordings.get(rec_id)
if rec is None:
raise HTTPException(status_code=404, detail="No such recording")
@app.get("/api/recordings/{rec_id}")
def get_recording(rec_id: str) -> dict:
rec = store.get(rec_id)
if not rec:
raise HTTPException(404, "No such recording")
return rec return rec
@app.get("/api/recordings/{rec_id}/audio") @app.get("/api/v1/recordings/{rec_id}/transcript", response_model=Transcript, tags=["server"])
def get_audio(rec_id: str): def get_transcript(rec_id: str) -> Transcript:
rec = store.get(rec_id) # Implemented in M5 (faster-whisper). Until then, signal "not transcribed yet".
if not rec: if rec_id not in _recordings:
raise HTTPException(404, "No such recording") raise HTTPException(status_code=404, detail="No such recording")
return FileResponse(store.audio_path(rec_id), media_type=rec["mime"] or "audio/webm") raise HTTPException(status_code=409, detail="Not transcribed yet (M5)")
@app.delete("/api/recordings/{rec_id}", status_code=204) @app.get("/api/v1/recordings/{rec_id}/summary", response_model=Summary, tags=["server"])
def delete_recording(rec_id: str): def get_summary(rec_id: str) -> Summary:
if not store.delete(rec_id): # Implemented in M6 (Ollama). Until then, signal "not summarised yet".
raise HTTPException(404, "No such recording") if rec_id not in _recordings:
return Response(status_code=204) raise HTTPException(status_code=404, detail="No such recording")
raise HTTPException(status_code=409, detail="Not summarised yet (M6)")
def _ts(seconds: float, sep: str) -> str: @app.post("/api/v1/ingest", response_model=Recording, status_code=202, tags=["server"])
h, rem = divmod(int(seconds), 3600) def ingest(recording: Recording) -> Recording:
m, s = divmod(rem, 60) # M5 will store audio to the object store and queue transcription + summary.
ms = int((seconds - int(seconds)) * 1000) _recordings[recording.id] = recording
return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}" return recording
@app.get("/api/recordings/{rec_id}/export")
def export_recording(rec_id: str, format: str = "txt"):
rec = store.get(rec_id)
if not rec:
raise HTTPException(404, "No such recording")
t = rec.get("transcript") or {}
s = rec.get("summary") or {}
segs = t.get("segments") or []
if format == "json":
return Response(json.dumps(rec, indent=2), media_type="application/json")
if format == "txt":
return PlainTextResponse(t.get("text", ""))
if format == "md":
body = [f"# {rec['title']}", "", "## Summary", s.get("overview", ""), ""]
if s.get("key_points"):
body += ["### Key points"] + [f"- {p}" for p in s["key_points"]] + [""]
if s.get("action_items"):
body += ["### Action items"] + [f"- [ ] {a}" for a in s["action_items"]] + [""]
body += ["## Transcript", "", t.get("text", "")]
return PlainTextResponse("\n".join(body), media_type="text/markdown")
if format in ("srt", "vtt"):
sep = "," if format == "srt" else "."
lines = ["WEBVTT", ""] if format == "vtt" else []
for i, seg in enumerate(segs, 1):
if format == "srt":
lines.append(str(i))
lines.append(f"{_ts(seg['start'], sep)} --> {_ts(seg['end'], sep)}")
lines.append(seg["text"].strip())
lines.append("")
return PlainTextResponse("\n".join(lines))
raise HTTPException(400, "Unknown format")
# Serve the web UI at the root (must be mounted after the API routes).
app.mount("/", StaticFiles(directory=WEB_DIR, html=True), name="web")

View file

@ -1,36 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-only
"""The processing pipeline: audio -> transcript -> summary, using the provider layer.
Run as a background task after upload. Updates the recording's status as it goes so the
web UI can show progress (queued -> transcribing -> summarising -> done).
"""
from __future__ import annotations
from . import store
from .providers import build_summariser, build_transcriber
def process(rec_id: str) -> None:
path = store.audio_path(rec_id)
try:
store.set_status(rec_id, "transcribing")
transcript = build_transcriber().transcribe(path)
store.set_status(rec_id, "summarising")
summary = build_summariser().summarise(rec_id, transcript.text)
store.set_result(
rec_id,
language=transcript.language,
transcript={
"text": transcript.text,
"segments": [s.model_dump() for s in transcript.segments],
},
summary={
"overview": summary.overview,
"key_points": summary.key_points,
"action_items": summary.action_items,
},
)
except Exception as exc: # surface any provider/network error to the UI
store.set_status(rec_id, "error", f"{type(exc).__name__}: {exc}")

View file

@ -1,107 +0,0 @@
# SPDX-License-Identifier: GPL-3.0-only
"""Tiny persistence for the web app: recordings in SQLite, audio on disk.
Deliberately dependency-free (sqlite3 is stdlib) so the demo runs anywhere. Not meant for
high concurrency; the hosted service uses Postgres + object storage (see docs/).
"""
from __future__ import annotations
import json
import os
import sqlite3
import time
import uuid
from typing import Any
from .config import settings
_DB = os.path.join(settings.local_media_dir, "nightjar.db")
def _conn() -> sqlite3.Connection:
os.makedirs(settings.local_media_dir, exist_ok=True)
c = sqlite3.connect(_DB)
c.row_factory = sqlite3.Row
return c
def init() -> None:
with _conn() as c:
c.execute(
"""CREATE TABLE IF NOT EXISTS recordings (
id TEXT PRIMARY KEY,
title TEXT,
filename TEXT,
mime TEXT,
created_at REAL,
status TEXT, -- queued|transcribing|summarising|done|error
error TEXT,
language TEXT,
transcript TEXT, -- JSON: {text, segments}
summary TEXT -- JSON: {overview, key_points, action_items}
)"""
)
def audio_path(rec_id: str) -> str:
return os.path.join(settings.local_media_dir, f"{rec_id}.audio")
def create(title: str, filename: str, mime: str) -> str:
rec_id = "rec_" + uuid.uuid4().hex[:12]
with _conn() as c:
c.execute(
"INSERT INTO recordings (id,title,filename,mime,created_at,status) "
"VALUES (?,?,?,?,?,?)",
(rec_id, title, filename, mime, time.time(), "queued"),
)
return rec_id
def set_status(rec_id: str, status: str, error: str | None = None) -> None:
with _conn() as c:
c.execute("UPDATE recordings SET status=?, error=? WHERE id=?", (status, error, rec_id))
def set_result(rec_id: str, language: str, transcript: dict, summary: dict) -> None:
with _conn() as c:
c.execute(
"UPDATE recordings SET status='done', language=?, transcript=?, summary=? WHERE id=?",
(language, json.dumps(transcript), json.dumps(summary), rec_id),
)
def _row(r: sqlite3.Row) -> dict[str, Any]:
return {
"id": r["id"],
"title": r["title"],
"created_at": r["created_at"],
"status": r["status"],
"error": r["error"],
"language": r["language"],
"mime": r["mime"],
"transcript": json.loads(r["transcript"]) if r["transcript"] else None,
"summary": json.loads(r["summary"]) if r["summary"] else None,
}
def get(rec_id: str) -> dict | None:
with _conn() as c:
r = c.execute("SELECT * FROM recordings WHERE id=?", (rec_id,)).fetchone()
return _row(r) if r else None
def list_all() -> list[dict]:
with _conn() as c:
rows = c.execute("SELECT * FROM recordings ORDER BY created_at DESC").fetchall()
return [_row(r) for r in rows]
def delete(rec_id: str) -> bool:
with _conn() as c:
cur = c.execute("DELETE FROM recordings WHERE id=?", (rec_id,))
try:
os.remove(audio_path(rec_id))
except OSError:
pass
return cur.rowcount > 0

View file

@ -1,103 +0,0 @@
// SPDX-License-Identifier: GPL-3.0-only - Nightjar web UI logic (vanilla JS)
(function () {
const $ = (s) => document.querySelector(s);
const listEl = $("#list"), detailEl = $("#detail");
const recBtn = $("#recBtn"), recTimer = $("#recTimer"), uploadEl = $("#upload");
let selected = null, poller = null;
let mediaRecorder = null, chunks = [], recStart = 0, recInterval = null;
const PROC = ["queued", "transcribing", "summarising"];
const fmtTime = (s) => { s = Math.floor(s || 0); const m = Math.floor(s / 60); return `${m}:${String(s % 60).padStart(2, "0")}`; };
const when = (t) => new Date((t || 0) * 1000).toLocaleString();
const esc = (x) => (x || "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
async function j(url, opts) { const r = await fetch(url, opts); if (!r.ok && r.status !== 204) throw new Error(await r.text()); return r.status === 204 ? null : r.json(); }
function badge(status) {
if (status === "done") return '<span class="badge done">done</span>';
if (status === "error") return '<span class="badge error">error</span>';
return `<span class="badge proc">${status}</span>`;
}
async function refreshList() {
const items = await j("/api/recordings");
listEl.innerHTML = items.map((r) => `
<li class="item ${r.id === selected ? "sel" : ""}" data-id="${r.id}">
<div class="t">${esc(r.title)}</div>
<div class="m">${badge(r.status)} <span>${when(r.created_at)}</span></div>
</li>`).join("") || '<li class="m" style="padding:12px;color:var(--muted)">No recordings yet.</li>';
listEl.querySelectorAll(".item").forEach((li) => li.onclick = () => open(li.dataset.id));
// keep polling if anything is still processing
if (items.some((r) => PROC.includes(r.status)) && !poller) poller = setInterval(tick, 2500);
if (!items.some((r) => PROC.includes(r.status)) && poller) { clearInterval(poller); poller = null; }
}
async function tick() { await refreshList(); if (selected) await open(selected, true); }
async function open(id, quiet) {
selected = id;
if (!quiet) listEl.querySelectorAll(".item").forEach((li) => li.classList.toggle("sel", li.dataset.id === id));
const r = await j("/api/recordings/" + id);
const s = r.summary, t = r.transcript;
let mid;
if (PROC.includes(r.status)) mid = `<div class="card"><span class="spinner"></span> &nbsp;${r.status}… this can take a few seconds.</div>`;
else if (r.status === "error") mid = `<div class="card"><h3 style="color:var(--bad)">Processing failed</h3><p style="color:var(--muted)">${esc(r.error)}</p><p style="color:var(--muted)">Check the server&#39;s AI provider config (see .env).</p></div>`;
else mid = `
${s ? `<div class="card"><h3>Summary</h3><p class="overview">${esc(s.overview)}</p>
${s.key_points && s.key_points.length ? `<h4>Key points</h4><ul>${s.key_points.map((p) => `<li>${esc(p)}</li>`).join("")}</ul>` : ""}
${s.action_items && s.action_items.length ? `<h4>Action items</h4><ul>${s.action_items.map((a) => `<li>${esc(a)}</li>`).join("")}</ul>` : ""}</div>` : ""}
<div class="card"><h3>Transcript</h3>
${(t && t.segments && t.segments.length)
? t.segments.map((g) => `<div class="seg"><div class="ts">${fmtTime(g.start)}</div><div class="tx">${esc(g.text)}</div></div>`).join("")
: `<div class="overview">${esc(t && t.text || "")}</div>`}</div>
<div class="exports"><span style="color:var(--muted);font-size:.82rem;align-self:center">Export:</span>
${["txt", "md", "srt", "vtt", "json"].map((f) => `<a href="/api/recordings/${id}/export?format=${f}" download="${id}.${f}">${f.toUpperCase()}</a>`).join("")}</div>`;
detailEl.innerHTML = `
<div class="head"><h1>${esc(r.title)}</h1>
<div class="sub">${badge(r.status)} <span>${when(r.created_at)}</span>
<button class="link-danger right" id="delBtn">Delete</button></div>
<audio controls preload="metadata" src="/api/recordings/${id}/audio"></audio>
</div>${mid}`;
$("#delBtn").onclick = async () => { if (confirm("Delete this recording?")) { await j("/api/recordings/" + id, { method: "DELETE" }); selected = null; detailEl.innerHTML = ""; refreshList(); } };
}
async function upload(blob, name) {
const fd = new FormData();
fd.append("file", blob, name);
const r = await j("/api/recordings?title=" + encodeURIComponent(name), { method: "POST", body: fd });
await refreshList();
open(r.id);
if (!poller) poller = setInterval(tick, 2500);
}
uploadEl.onchange = () => { const f = uploadEl.files[0]; if (f) upload(f, f.name); uploadEl.value = ""; };
recBtn.onclick = async () => {
if (mediaRecorder && mediaRecorder.state === "recording") { mediaRecorder.stop(); return; }
let stream;
try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); }
catch (e) { alert("Microphone access denied or unavailable."); return; }
chunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
mediaRecorder.onstop = () => {
stream.getTracks().forEach((t) => t.stop());
clearInterval(recInterval); recTimer.hidden = true;
recBtn.classList.remove("recording"); recBtn.textContent = "● Record";
const blob = new Blob(chunks, { type: mediaRecorder.mimeType || "audio/webm" });
const name = "Recording " + new Date().toLocaleString();
upload(blob, name.replace(/[/:]/g, "-") + ".webm");
};
mediaRecorder.start();
recStart = Date.now(); recTimer.hidden = false;
recInterval = setInterval(() => recTimer.textContent = fmtTime((Date.now() - recStart) / 1000) + " · recording…", 250);
recBtn.classList.add("recording"); recBtn.textContent = "■ Stop";
};
async function boot() {
try { const h = await j("/health"); $("#providers").textContent = `stt: ${h.transcriber} · llm: ${h.summariser}`; } catch (e) {}
refreshList();
}
boot();
})();

View file

@ -1,38 +0,0 @@
<!doctype html>
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<html lang="en-GB">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nightjar</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<header class="topbar">
<div class="brand"><span class="dot"></span> Nightjar</div>
<div class="providers" id="providers" title="Configured AI providers"></div>
</header>
<main class="layout">
<aside class="sidebar">
<div class="actions">
<button id="recBtn" class="btn primary">● Record</button>
<label class="btn" for="upload">Upload</label>
<input id="upload" type="file" accept="audio/*" hidden />
</div>
<div id="recTimer" class="rec-timer" hidden>00:00 · recording…</div>
<ul id="list" class="list"></ul>
</aside>
<section class="detail" id="detail">
<div class="empty">
<h2>Record or upload audio</h2>
<p>Nightjar transcribes it and writes a summary using the AI you configured. Your
recordings appear on the left.</p>
</div>
</section>
</main>
<script src="/app.js"></script>
</body>
</html>

View file

@ -1,54 +0,0 @@
/* SPDX-License-Identifier: GPL-3.0-only - Nightjar web UI */
:root{
--ink:#0e1116; --panel:#161b22; --panel2:#1c2230; --line:#2a3140; --text:#e8edf4;
--muted:#93a0b4; --accent:#e0894e; --accent2:#5fc7bf; --ok:#3fb950; --bad:#f85149;
--sans:ui-sans-serif,system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
--mono:ui-monospace,"Cascadia Code",Consolas,monospace;
}
*{box-sizing:border-box} html,body{height:100%}
body{margin:0;font-family:var(--sans);color:var(--text);background:var(--ink);}
.topbar{display:flex;align-items:center;justify-content:space-between;height:56px;padding:0 18px;border-bottom:1px solid var(--line);background:rgba(14,17,22,.8);backdrop-filter:blur(8px);position:sticky;top:0;z-index:5}
.brand{font-weight:800;letter-spacing:.3px;display:flex;align-items:center;gap:10px}
.brand .dot{width:12px;height:12px;border-radius:50%;background:radial-gradient(circle at 40% 40%,var(--accent2),transparent 70%),var(--accent);box-shadow:0 0 12px rgba(95,199,191,.6)}
.providers{font-family:var(--mono);font-size:.72rem;color:var(--muted)}
.layout{display:grid;grid-template-columns:320px 1fr;height:calc(100vh - 56px)}
.sidebar{border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;background:var(--panel)}
.actions{display:flex;gap:8px;padding:14px}
.btn{cursor:pointer;border:1px solid var(--line);background:var(--panel2);color:var(--text);border-radius:10px;padding:9px 14px;font-weight:600;font-size:.92rem;text-align:center;flex:1;transition:.14s}
.btn:hover{border-color:var(--accent)}
.btn.primary{background:linear-gradient(135deg,var(--accent),#d1783c);border:none;color:#14100a}
.btn.recording{background:var(--bad);color:#fff;animation:pulse 1.4s infinite}
@keyframes pulse{50%{opacity:.6}}
.rec-timer{padding:0 14px 8px;color:var(--bad);font-family:var(--mono);font-size:.8rem}
.list{list-style:none;margin:0;padding:6px;overflow-y:auto;flex:1}
.item{padding:11px 12px;border-radius:10px;cursor:pointer;border:1px solid transparent}
.item:hover{background:var(--panel2)}
.item.sel{background:var(--panel2);border-color:var(--accent)}
.item .t{font-weight:600;font-size:.94rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.item .m{display:flex;gap:8px;align-items:center;margin-top:3px;color:var(--muted);font-size:.76rem}
.badge{font-size:.68rem;padding:1px 7px;border-radius:999px;border:1px solid var(--line);font-family:var(--mono)}
.badge.done{color:var(--ok);border-color:#2ea04355}
.badge.error{color:var(--bad);border-color:#f8514955}
.badge.proc{color:var(--accent2);border-color:#5fc7bf55}
.detail{overflow-y:auto;padding:26px 30px}
.empty{max-width:460px;margin:12vh auto 0;text-align:center;color:var(--muted)}
.empty h2{color:var(--text)}
.head h1{margin:0 0 4px;font-size:1.5rem}
.head .sub{color:var(--muted);font-size:.85rem;display:flex;gap:10px;align-items:center;margin-bottom:16px}
audio{width:100%;margin:6px 0 20px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px 20px;margin-bottom:18px}
.card h3{margin:0 0 10px;font-size:1.05rem}
.card ul{margin:6px 0 0;padding-left:18px;color:#d7deea}
.card li{margin:4px 0}
.overview{color:#d7deea;line-height:1.6}
.seg{display:flex;gap:12px;padding:6px 0;border-bottom:1px solid #1f2733}
.seg .ts{font-family:var(--mono);color:var(--accent2);font-size:.78rem;min-width:64px;padding-top:2px}
.seg .tx{color:#cdd6e2;line-height:1.5}
.exports{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px}
.exports a{font-size:.82rem;text-decoration:none;color:var(--text);border:1px solid var(--line);border-radius:8px;padding:6px 11px}
.exports a:hover{border-color:var(--accent)}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--line);border-top-color:var(--accent2);border-radius:50%;animation:spin .8s linear infinite;vertical-align:-2px}
@keyframes spin{to{transform:rotate(360deg)}}
.right{margin-left:auto}
.link-danger{color:var(--bad);cursor:pointer;font-size:.82rem;background:none;border:none}
@media(max-width:720px){.layout{grid-template-columns:1fr}.sidebar{display:none}}

View file

@ -65,11 +65,6 @@
## Done ## Done
- [x] Nightjar web app (Plaud-style): browser record/upload -> transcribe -> summarise ->
library, playback, transcript, summary, export. FastAPI + vanilla-JS SPA in `server/`,
wired to the provider layer (Groq/OpenAI/Anthropic/local). Runnable demo; this is also
the practical landing of M5/M6 for the HTTP providers. Config prefix switched to
NIGHTJAR_. (PR: feature/web-app, 2026-07-06)
- [x] Dev testing + emulation harness - native unit tests (wav/range), Wokwi emulator, - [x] Dev testing + emulation harness - native unit tests (wav/range), Wokwi emulator,
cppcheck + OpenAPI validation in CI, docs/testing.md (PR #3, 2026-07-03). cppcheck + OpenAPI validation in CI, docs/testing.md (PR #3, 2026-07-03).
- [x] M2 On-device WiFi + REST API - WiFi/SoftAP + mDNS + config(NVS) + device REST API - [x] M2 On-device WiFi + REST API - WiFi/SoftAP + mDNS + config(NVS) + device REST API