diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..dd6c6b6 --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/README.md b/server/README.md index 650de40..1c136ca 100644 --- a/server/README.md +++ b/server/README.md @@ -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. \ No newline at end of file +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`. diff --git a/server/app/config.py b/server/app/config.py index 6c3f84b..bf63d14 100644 --- a/server/app/config.py +++ b/server/app/config.py @@ -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 diff --git a/server/app/main.py b/server/app/main.py index 9e2a071..6554dd5 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -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 \ No newline at end of file +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") diff --git a/server/app/pipeline.py b/server/app/pipeline.py new file mode 100644 index 0000000..984649f --- /dev/null +++ b/server/app/pipeline.py @@ -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}") diff --git a/server/app/store.py b/server/app/store.py new file mode 100644 index 0000000..690a3dd --- /dev/null +++ b/server/app/store.py @@ -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 diff --git a/server/app/web/app.js b/server/app/web/app.js new file mode 100644 index 0000000..692862e --- /dev/null +++ b/server/app/web/app.js @@ -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, "&").replace(//g, ">"); + + 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 'done'; + if (status === "error") return 'error'; + return `${status}`; + } + + async function refreshList() { + const items = await j("/api/recordings"); + listEl.innerHTML = items.map((r) => ` +
  • +
    ${esc(r.title)}
    +
    ${badge(r.status)} ${when(r.created_at)}
    +
  • `).join("") || '
  • No recordings yet.
  • '; + 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 = `
     ${r.status}… this can take a few seconds.
    `; + else if (r.status === "error") mid = `

    Processing failed

    ${esc(r.error)}

    Check the server's AI provider config (see .env).

    `; + else mid = ` + ${s ? `

    Summary

    ${esc(s.overview)}

    + ${s.key_points && s.key_points.length ? `

    Key points

    ` : ""} + ${s.action_items && s.action_items.length ? `

    Action items

    ` : ""}
    ` : ""} +

    Transcript

    + ${(t && t.segments && t.segments.length) + ? t.segments.map((g) => `
    ${fmtTime(g.start)}
    ${esc(g.text)}
    `).join("") + : `
    ${esc(t && t.text || "")}
    `}
    +
    Export: + ${["txt", "md", "srt", "vtt", "json"].map((f) => `${f.toUpperCase()}`).join("")}
    `; + + detailEl.innerHTML = ` +

    ${esc(r.title)}

    +
    ${badge(r.status)} ${when(r.created_at)} +
    + +
    ${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(); +})(); diff --git a/server/app/web/index.html b/server/app/web/index.html new file mode 100644 index 0000000..846c556 --- /dev/null +++ b/server/app/web/index.html @@ -0,0 +1,38 @@ + + + + + + + Nightjar + + + +
    +
    Nightjar
    +
    +
    + +
    + + +
    +
    +

    Record or upload audio

    +

    Nightjar transcribes it and writes a summary using the AI you configured. Your + recordings appear on the left.

    +
    +
    +
    + + + + diff --git a/server/app/web/styles.css b/server/app/web/styles.css new file mode 100644 index 0000000..f163174 --- /dev/null +++ b/server/app/web/styles.css @@ -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}} diff --git a/state/TODO.md b/state/TODO.md index 9006bfc..50e4443 100644 --- a/state/TODO.md +++ b/state/TODO.md @@ -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