diff --git a/server/Dockerfile b/server/Dockerfile deleted file mode 100644 index dd6c6b6..0000000 --- a/server/Dockerfile +++ /dev/null @@ -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"] diff --git a/server/README.md b/server/README.md index 1c136ca..650de40 100644 --- a/server/README.md +++ b/server/README.md @@ -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 -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. +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. -## 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 -both transcription and summaries. +## Run (dev) ```bash cd server python -m venv .venv -. .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) +. .venv/Scripts/activate # Windows; or: . .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # edit as needed uvicorn app.main:app --reload ``` -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). +- API docs (Swagger UI): http://localhost:8000/docs +- Health: http://localhost:8000/health -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 -cd server -docker build -t nightjar . -docker run -p 8000:8000 --env-file .env -v "$PWD/media:/app/media" nightjar -``` +- **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. -## 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 -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 +## Layout ``` -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) +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 ``` -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`. +## API -## 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`. +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 diff --git a/server/app/config.py b/server/app/config.py index bf63d14..6c3f84b 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="NIGHTJAR_", env_file=".env") + model_config = SettingsConfigDict(env_prefix="OPENSCRIBE_", 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 6554dd5..9e2a071 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -1,136 +1,77 @@ # 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 -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. +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. """ from __future__ import annotations -import json -import os -from contextlib import asynccontextmanager +from fastapi import FastAPI, HTTPException -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 .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.", +) - -@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) +# In-memory store stands in for the DB + object storage until M5. +_recordings: dict[str, Recording] = {} @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: + except Exception as exc: # e.g. anthropic dep not installed 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.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", response_model=RecordingPage, tags=["recordings"]) +def list_recordings(limit: int = 50) -> RecordingPage: + return RecordingPage(items=list(_recordings.values())[:limit], next_cursor=None) -@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") +@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") return rec -@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}/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.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.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)") -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") +@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 diff --git a/server/app/pipeline.py b/server/app/pipeline.py deleted file mode 100644 index 984649f..0000000 --- a/server/app/pipeline.py +++ /dev/null @@ -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}") diff --git a/server/app/store.py b/server/app/store.py deleted file mode 100644 index 690a3dd..0000000 --- a/server/app/store.py +++ /dev/null @@ -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 diff --git a/server/app/web/app.js b/server/app/web/app.js deleted file mode 100644 index 692862e..0000000 --- a/server/app/web/app.js +++ /dev/null @@ -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, "&").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 deleted file mode 100644 index 846c556..0000000 --- a/server/app/web/index.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - 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 deleted file mode 100644 index f163174..0000000 --- a/server/app/web/styles.css +++ /dev/null @@ -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}} diff --git a/state/TODO.md b/state/TODO.md index 50e4443..9006bfc 100644 --- a/state/TODO.md +++ b/state/TODO.md @@ -65,11 +65,6 @@ ## 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