feat(phase-1): persistent generation library

- Save every completed generation to SQLite (generation_store.py) with
  WAV and waveform peaks written to data/generations/<id>/
- Deferred DB write until success — cancelled/errored generations never
  touch the DB and never appear in the library
- Fixed cancel+regenerate IndexError: _reset_scheduler_caches() now
  directly zeros scheduler._step_index and running state in addition to
  clearing VibePod cache dicts; same explicit resets added in the fresh
  path of prepare_noise_scheduler as belt-and-suspenders
- Added /library page with GenerationCard, WaveformPreview, waveform
  fetch, play/pause, download, delete, pagination, empty + error states
- Added generation API routes (list, single, audio stream, waveform,
  delete) proxying to Python server
- Added Library nav link to Header with active state
- Persist script/speaker/CFG to localStorage so generate page state
  survives navigation
- Updated build plan: Phase 0+1 ticked off, better-sqlite3 moved to
  Phase 2, architectural note on Python owning all persistence
This commit is contained in:
2026-05-02 23:05:11 +01:00
parent 47e0c7e512
commit 13085166fb
13 changed files with 913 additions and 29 deletions
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
const pythonUrl = () => process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
const res = await fetch(`${pythonUrl()}/generations/${id}/audio`);
if (!res.ok) return NextResponse.json({ error: "Audio not found" }, { status: res.status });
return new NextResponse(res.body, {
status: 200,
headers: {
"Content-Type": "audio/wav",
"Content-Disposition": `attachment; filename="${id}.wav"`,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch {
return NextResponse.json({ error: "Failed to reach server" }, { status: 502 });
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
const pythonUrl = () => process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
const res = await fetch(`${pythonUrl()}/generations/${id}`, { cache: "no-store" });
if (!res.ok) return NextResponse.json({ error: "Not found" }, { status: res.status });
return NextResponse.json(await res.json());
} catch {
return NextResponse.json({ error: "Failed to reach server" }, { status: 502 });
}
}
export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
const res = await fetch(`${pythonUrl()}/generations/${id}`, { method: "DELETE" });
if (res.status === 404)
return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!res.ok) return NextResponse.json({ error: "Upstream error" }, { status: res.status });
return new NextResponse(null, { status: 204 });
} catch {
return NextResponse.json({ error: "Failed to reach server" }, { status: 502 });
}
}
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
const pythonUrl = () => process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
try {
const res = await fetch(`${pythonUrl()}/generations/${id}/waveform`, { cache: "no-store" });
if (!res.ok) return NextResponse.json({ error: "Waveform not found" }, { status: res.status });
return NextResponse.json(await res.json(), {
headers: { "Cache-Control": "public, max-age=31536000, immutable" },
});
} catch {
return NextResponse.json({ error: "Failed to reach server" }, { status: 502 });
}
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
const pythonUrl = () => process.env.VIBEVOICE_SERVER_URL ?? "http://localhost:8000";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const limit = searchParams.get("limit") ?? "50";
const offset = searchParams.get("offset") ?? "0";
try {
const res = await fetch(`${pythonUrl()}/generations?limit=${limit}&offset=${offset}`, {
cache: "no-store",
});
if (!res.ok) return NextResponse.json({ error: "Upstream error" }, { status: res.status });
return NextResponse.json(await res.json());
} catch {
return NextResponse.json({ error: "Failed to reach server" }, { status: 502 });
}
}
+162
View File
@@ -0,0 +1,162 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Header from "@/components/Header";
import GenerationCard from "@/components/GenerationCard";
import type { GenerationJob, GenerationsListResponse } from "@/lib/types/generation";
const PAGE_SIZE = 24;
export default function LibraryPage() {
const [jobs, setJobs] = useState<GenerationJob[]>([]);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchJobs = useCallback(async (currentOffset: number, replace: boolean) => {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/generations?limit=${PAGE_SIZE}&offset=${currentOffset}`,
{ cache: "no-store" }
);
if (!res.ok) throw new Error(`Server returned ${res.status}`);
const data = (await res.json()) as GenerationsListResponse;
setJobs((prev) => (replace ? data.items : [...prev, ...data.items]));
setHasMore(data.items.length === PAGE_SIZE);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load generations");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchJobs(0, true);
}, [fetchJobs]);
function handleDelete(id: string) {
setJobs((prev) => prev.filter((j) => j.id !== id));
}
function handleLoadMore() {
const next = offset + PAGE_SIZE;
setOffset(next);
fetchJobs(next, false);
}
return (
<div className="min-h-screen flex flex-col" style={{ background: "var(--background)" }}>
<Header />
<main className="flex-1 max-w-6xl mx-auto w-full px-6 py-8">
{/* Page header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2
className="text-xl font-bold tracking-tight"
style={{ color: "var(--foreground)" }}
>
Generation Library
</h2>
<p className="text-sm mt-0.5" style={{ color: "var(--muted)" }}>
Every completed generation is saved here.
</p>
</div>
<a
href="/"
className="px-4 py-2 rounded-lg text-sm font-medium border transition-colors"
style={{
background: "var(--accent-teal-dim)",
borderColor: "transparent",
color: "var(--accent-teal)",
}}
>
+ New Generation
</a>
</div>
{/* Error state */}
{error && (
<div
className="rounded-xl border px-4 py-3 text-sm mb-6"
style={{
background: "color-mix(in srgb, var(--error) 10%, transparent)",
borderColor: "var(--error)",
color: "var(--error)",
}}
>
{error} {" "}
<button
className="underline"
onClick={() => fetchJobs(0, true)}
>
retry
</button>
</div>
)}
{/* Empty state */}
{!loading && jobs.length === 0 && !error && (
<div
className="rounded-xl border px-8 py-16 text-center"
style={{ borderColor: "var(--border)", borderStyle: "dashed" }}
>
<p className="text-4xl mb-4">🎙</p>
<p className="font-semibold mb-1" style={{ color: "var(--foreground)" }}>
No generations yet
</p>
<p className="text-sm mb-4" style={{ color: "var(--muted)" }}>
Generate some audio and it will appear here automatically.
</p>
<a
href="/"
className="inline-block px-4 py-2 rounded-lg text-sm font-medium"
style={{
background: "var(--accent-teal-dim)",
color: "var(--accent-teal)",
}}
>
Go generate something
</a>
</div>
)}
{/* Grid */}
{jobs.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{jobs.map((job) => (
<GenerationCard key={job.id} job={job} onDelete={handleDelete} />
))}
</div>
)}
{/* Load more */}
{hasMore && !loading && jobs.length > 0 && (
<div className="mt-8 text-center">
<button
onClick={handleLoadMore}
className="px-6 py-2 rounded-lg text-sm font-medium border transition-colors"
style={{
background: "transparent",
borderColor: "var(--border)",
color: "var(--foreground)",
}}
>
Load more
</button>
</div>
)}
{/* Loading spinner */}
{loading && (
<div className="mt-8 text-center text-sm" style={{ color: "var(--muted)" }}>
Loading
</div>
)}
</main>
</div>
);
}
+32 -1
View File
@@ -171,8 +171,39 @@ const initialState: AppState = {
serverConfig: null,
};
const STORAGE_KEY = "vibepod_form";
function loadSavedForm(): Partial<Pick<AppState, "script" | "speaker" | "cfgScale">> {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
return JSON.parse(raw) as Partial<Pick<AppState, "script" | "speaker" | "cfgScale">>;
} catch {
return {};
}
}
export default function HomePage() {
const [state, dispatch] = useReducer(reducer, initialState);
const [state, dispatch] = useReducer(reducer, initialState, (base) => {
const saved = loadSavedForm();
return {
...base,
...(saved.script !== undefined && { script: saved.script }),
...(saved.speaker !== undefined && { speaker: saved.speaker }),
...(typeof saved.cfgScale === "number" && { cfgScale: saved.cfgScale }),
};
});
// Persist user-editable form fields across navigation.
useEffect(() => {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ script: state.script, speaker: state.speaker, cfgScale: state.cfgScale })
);
} catch {}
}, [state.script, state.speaker, state.cfgScale]);
const wordCount = state.script.trim() === "" ? 0 : state.script.trim().split(/\s+/).length;