feat(web): Nightjar web app - Plaud-style record/transcribe/summarise UI
Some checks failed
ci / firmware (pull_request) Failing after 2s
ci / openapi (pull_request) Failing after 32s
ci / emulator (pull_request) Failing after 35s
ci / server (pull_request) Failing after 29s

A runnable, self-hosted web interface so the whole workflow can be tested end to
end: record or upload audio in the browser, watch it transcribe and summarise,
browse a library, play back, and export.

What changed:
- server/app/store.py: SQLite + on-disk audio storage for recordings (stdlib only).
- server/app/pipeline.py: background task audio -> transcribe -> summarise via the
  existing provider layer, updating status (queued/transcribing/summarising/done/error).
- server/app/main.py: web API - POST upload, list, detail, audio (Range), delete, and
  export (txt/md/srt/vtt/json) - and serves the SPA at /.
- server/app/web/: Plaud-style single-page UI (index.html, styles.css, app.js). Sidebar
  library, in-browser recording (MediaRecorder) + file upload, live status polling,
  audio player, summary (overview/key points/actions), timestamped transcript, exports.
- server/Dockerfile + README: two-minute run instructions (default provider: Groq free
  tier for both Whisper + LLM), and a Docker option.
- config: env prefix switched OPENSCRIBE_ -> NIGHTJAR_ to match the brand and the site
  tutorials; .env.example rewritten with a ready Groq quick-start.
- state/TODO: web app recorded as done.

Why:
- User asked for a Plaud-like web interface to test how it all works. Nothing testable
  existed before (marketing site is a brochure; pipeline was unwired). This delivers a
  real, runnable product demo and effectively lands M5/M6 for the HTTP providers.

Notes:
- Slim by design: AI is offloaded to the configured provider, so no local ML deps needed
  for the demo. Byte-compiles clean; JS passes node --check. Local faster-whisper still
  needs its model (M5) for the fully-offline path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Laurence 2026-07-06 09:57:42 +01:00
parent b9e56d0825
commit 19c3e156a0
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), A self-hosted FastAPI app with a **Plaud-style web interface**: upload or record audio in
summarises them (Ollama), and serves the open API with exports. Everything runs on the browser, watch it transcribe and summarise, browse a library, play back, and export.
hardware you own. 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 ## Try it in two minutes
> stubs. Transcription lands in M5, summaries in M6, real storage/DB alongside.
## Run (dev) You need one AI key. The default config uses **Groq** (free tier, fast Whisper + LLM) for
both transcription and summaries.
```bash ```bash
cd server cd server
python -m venv .venv python -m venv .venv
. .venv/Scripts/activate # Windows; or: . .venv/bin/activate . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt pip install fastapi "uvicorn[standard]" pydantic pydantic-settings python-multipart httpx anthropic
cp .env.example .env # edit as needed cp .env.example .env # then paste your Groq key into .env (both API_KEY lines)
uvicorn app.main:app --reload uvicorn app.main:app --reload
``` ```
- API docs (Swagger UI): http://localhost:8000/docs Open **http://localhost:8000** and press Record (or Upload an audio file). It will show
- Health: http://localhost:8000/health `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`. ```bash
- **Ollama** for summaries - `ollama serve` and `ollama pull llama3.1`. cd server
- **faster-whisper** downloads its model on first use; CPU works, CUDA is faster. docker build -t nightjar .
docker run -p 8000:8000 --env-file .env -v "$PWD/media:/app/media" nightjar
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
``` ```
## API ## Choosing a different AI
The contract is `../api/openapi.yaml`. The device implements the LAN "device" paths; this Everything is set in `.env` (see `.env.example` and `docs/ai-providers.md`): OpenAI or any
server implements ingest, transcript, summary and export. 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): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="OPENSCRIBE_", env_file=".env") model_config = SettingsConfigDict(env_prefix="NIGHTJAR_", 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,77 +1,136 @@
# SPDX-License-Identifier: GPL-3.0-only # 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 Upload or record audio, watch it transcribe and summarise, browse a library, play back and
is real and browsable at /docs. The AI pipeline (faster-whisper transcription in M5, export. The heavy AI is done by whichever providers you configure (see .env.example);
Ollama summaries in M6) and real storage/DB replace the stubs in later milestones. point it at a free Groq key and it works end to end.
""" """
from __future__ import annotations 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 .config import settings
from .models import Recording, RecordingPage, Summary, Transcript from .pipeline import process
app = FastAPI( WEB_DIR = os.path.join(os.path.dirname(__file__), "web")
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.
_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") @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: # e.g. anthropic dep not installed except Exception as exc:
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.get("/api/v1/recordings", response_model=RecordingPage, tags=["recordings"]) @app.post("/api/recordings")
def list_recordings(limit: int = 50) -> RecordingPage: async def create_recording(
return RecordingPage(items=list(_recordings.values())[:limit], next_cursor=None) 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"]) @app.get("/api/recordings")
def get_recording(rec_id: str) -> Recording: def list_recordings() -> list[dict]:
rec = _recordings.get(rec_id) return store.list_all()
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/v1/recordings/{rec_id}/transcript", response_model=Transcript, tags=["server"]) @app.get("/api/recordings/{rec_id}/audio")
def get_transcript(rec_id: str) -> Transcript: def get_audio(rec_id: str):
# Implemented in M5 (faster-whisper). Until then, signal "not transcribed yet". rec = store.get(rec_id)
if rec_id not in _recordings: if not rec:
raise HTTPException(status_code=404, detail="No such recording") raise HTTPException(404, "No such recording")
raise HTTPException(status_code=409, detail="Not transcribed yet (M5)") 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"]) @app.delete("/api/recordings/{rec_id}", status_code=204)
def get_summary(rec_id: str) -> Summary: def delete_recording(rec_id: str):
# Implemented in M6 (Ollama). Until then, signal "not summarised yet". if not store.delete(rec_id):
if rec_id not in _recordings: raise HTTPException(404, "No such recording")
raise HTTPException(status_code=404, detail="No such recording") return Response(status_code=204)
raise HTTPException(status_code=409, detail="Not summarised yet (M6)")
@app.post("/api/v1/ingest", response_model=Recording, status_code=202, tags=["server"]) def _ts(seconds: float, sep: str) -> str:
def ingest(recording: Recording) -> Recording: h, rem = divmod(int(seconds), 3600)
# M5 will store audio to the object store and queue transcription + summary. m, s = divmod(rem, 60)
_recordings[recording.id] = recording ms = int((seconds - int(seconds)) * 1000)
return recording 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 ## 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