Merge pull request 'Nightjar web app (Plaud-style record/transcribe/summarise UI)' (#7) from feature/web-app into main
Some checks failed
ci / firmware (push) Failing after 38s
ci / openapi (push) Failing after 36s
ci / emulator (push) Failing after 31s
ci / server (push) Failing after 30s

This commit is contained in:
laurence 2026-07-06 09:57:47 +01:00
commit f9bef7c30e
10 changed files with 501 additions and 76 deletions

11
server/Dockerfile Normal file
View file

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

View file

@ -1,77 +1,136 @@
# SPDX-License-Identifier: GPL-3.0-only
"""OpenScribe server - FastAPI app.
"""Nightjar web app - a Plaud-style interface plus the JSON API.
M0 scaffold: wires the routes from api/openapi.yaml with in-memory stubs so the API shape
is real and browsable at /docs. The AI pipeline (faster-whisper transcription in M5,
Ollama summaries in M6) and real storage/DB replace the stubs in later milestones.
Upload or record audio, watch it transcribe and summarise, browse a library, play back and
export. The heavy AI is done by whichever providers you configure (see .env.example);
point it at a free Groq key and it works end to end.
"""
from __future__ import annotations
from fastapi import FastAPI, HTTPException
import json
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 .models import Recording, RecordingPage, Summary, Transcript
from .pipeline import process
app = FastAPI(
title="OpenScribe API",
version="0.1.0",
description="Self-hosted AI voice recorder server. See api/openapi.yaml.",
)
WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
# In-memory store stands in for the DB + object storage until M5.
_recordings: dict[str, Recording] = {}
@asynccontextmanager
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")
def health() -> dict:
# Report the resolved provider names (never secrets) so operators can confirm config.
from .providers import build_summariser, build_transcriber
try:
summariser = build_summariser().name
except Exception as exc: # e.g. anthropic dep not installed
except Exception as exc:
summariser = f"unavailable ({type(exc).__name__})"
return {
"status": "ok",
"storage_backend": settings.storage_backend,
"transcription_provider": settings.transcription_provider,
"transcriber": build_transcriber().name,
"llm_provider": settings.llm_provider,
"summariser": summariser,
}
@app.get("/api/v1/recordings", response_model=RecordingPage, tags=["recordings"])
def list_recordings(limit: int = 50) -> RecordingPage:
return RecordingPage(items=list(_recordings.values())[:limit], next_cursor=None)
@app.post("/api/recordings")
async def create_recording(
file: UploadFile, background: BackgroundTasks, title: str | None = 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/v1/recordings/{rec_id}", response_model=Recording, tags=["recordings"])
def get_recording(rec_id: str) -> Recording:
rec = _recordings.get(rec_id)
if rec is None:
raise HTTPException(status_code=404, detail="No such recording")
@app.get("/api/recordings")
def list_recordings() -> list[dict]:
return store.list_all()
@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
@app.get("/api/v1/recordings/{rec_id}/transcript", response_model=Transcript, tags=["server"])
def get_transcript(rec_id: str) -> Transcript:
# Implemented in M5 (faster-whisper). Until then, signal "not transcribed yet".
if rec_id not in _recordings:
raise HTTPException(status_code=404, detail="No such recording")
raise HTTPException(status_code=409, detail="Not transcribed yet (M5)")
@app.get("/api/recordings/{rec_id}/audio")
def get_audio(rec_id: str):
rec = store.get(rec_id)
if not rec:
raise HTTPException(404, "No such recording")
return FileResponse(store.audio_path(rec_id), media_type=rec["mime"] or "audio/webm")
@app.get("/api/v1/recordings/{rec_id}/summary", response_model=Summary, tags=["server"])
def get_summary(rec_id: str) -> Summary:
# Implemented in M6 (Ollama). Until then, signal "not summarised yet".
if rec_id not in _recordings:
raise HTTPException(status_code=404, detail="No such recording")
raise HTTPException(status_code=409, detail="Not summarised yet (M6)")
@app.delete("/api/recordings/{rec_id}", status_code=204)
def delete_recording(rec_id: str):
if not store.delete(rec_id):
raise HTTPException(404, "No such recording")
return Response(status_code=204)
@app.post("/api/v1/ingest", response_model=Recording, status_code=202, tags=["server"])
def ingest(recording: Recording) -> Recording:
# M5 will store audio to the object store and queue transcription + summary.
_recordings[recording.id] = recording
return recording
def _ts(seconds: float, sep: str) -> str:
h, rem = divmod(int(seconds), 3600)
m, s = divmod(rem, 60)
ms = int((seconds - int(seconds)) * 1000)
return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}"
@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")

36
server/app/pipeline.py Normal file
View file

@ -0,0 +1,36 @@
# 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}")

107
server/app/store.py Normal file
View file

@ -0,0 +1,107 @@
# 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

103
server/app/web/app.js Normal file
View file

@ -0,0 +1,103 @@
// 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();
})();

38
server/app/web/index.html Normal file
View file

@ -0,0 +1,38 @@
<!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>

54
server/app/web/styles.css Normal file
View file

@ -0,0 +1,54 @@
/* 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,6 +65,11 @@
## 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,
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