feat(web): IndexedDB v2 migration; show seed and reuse button on history rows
Browse files- web/src/components/HistoryList.tsx +36 -19
- web/src/lib/api.ts +10 -2
- web/src/lib/idb.ts +14 -0
- web/src/pages/Studio.tsx +10 -4
- web/src/test/api.test.ts +10 -12
- web/src/test/idb.test.ts +42 -0
web/src/components/HistoryList.tsx
CHANGED
|
@@ -4,13 +4,14 @@ import { listHistory, type HistoryRecord } from "@/lib/idb";
|
|
| 4 |
type Props = {
|
| 5 |
refreshKey?: number;
|
| 6 |
onRegenerate: (h: HistoryRecord) => void;
|
|
|
|
| 7 |
};
|
| 8 |
|
| 9 |
function fmtTime(ts: number): string {
|
| 10 |
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
| 11 |
}
|
| 12 |
|
| 13 |
-
export default function HistoryList({ refreshKey, onRegenerate }: Props) {
|
| 14 |
const [items, setItems] = useState<HistoryRecord[]>([]);
|
| 15 |
useEffect(() => {
|
| 16 |
listHistory().then(setItems);
|
|
@@ -28,33 +29,49 @@ export default function HistoryList({ refreshKey, onRegenerate }: Props) {
|
|
| 28 |
<ul className="space-y-3">
|
| 29 |
{items.map((h, i) => {
|
| 30 |
const url = URL.createObjectURL(h.audioBlob);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
return (
|
| 32 |
<li key={h.id} className="card-paper p-3 space-y-2.5">
|
| 33 |
<div className="flex items-baseline justify-between gap-3">
|
| 34 |
<span className="marker-num">
|
| 35 |
{String(items.length - i).padStart(2, "0")}
|
| 36 |
</span>
|
| 37 |
-
<span className="label-mono">
|
| 38 |
-
{h.modelId.replace("chatterbox-", "")} · {h.language ?? "—"} · {fmtTime(h.createdAt)}
|
| 39 |
-
</span>
|
| 40 |
</div>
|
| 41 |
<p className="text-[13px] leading-snug line-clamp-3">{h.text}</p>
|
| 42 |
<audio controls src={url} className="w-full h-9" />
|
| 43 |
-
<div className="flex
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
</div>
|
| 59 |
</li>
|
| 60 |
);
|
|
|
|
| 4 |
type Props = {
|
| 5 |
refreshKey?: number;
|
| 6 |
onRegenerate: (h: HistoryRecord) => void;
|
| 7 |
+
onReuseSeed?: (seed: number) => void;
|
| 8 |
};
|
| 9 |
|
| 10 |
function fmtTime(ts: number): string {
|
| 11 |
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
| 12 |
}
|
| 13 |
|
| 14 |
+
export default function HistoryList({ refreshKey, onRegenerate, onReuseSeed }: Props) {
|
| 15 |
const [items, setItems] = useState<HistoryRecord[]>([]);
|
| 16 |
useEffect(() => {
|
| 17 |
listHistory().then(setItems);
|
|
|
|
| 29 |
<ul className="space-y-3">
|
| 30 |
{items.map((h, i) => {
|
| 31 |
const url = URL.createObjectURL(h.audioBlob);
|
| 32 |
+
const kindLabel =
|
| 33 |
+
h.kind === "dialog"
|
| 34 |
+
? `dialog · ${(h.speakers ?? []).length} spk · ${h.modelId.replace("chatterbox-", "")}`
|
| 35 |
+
: `${h.modelId.replace("chatterbox-", "")} · ${h.language ?? "—"}`;
|
| 36 |
return (
|
| 37 |
<li key={h.id} className="card-paper p-3 space-y-2.5">
|
| 38 |
<div className="flex items-baseline justify-between gap-3">
|
| 39 |
<span className="marker-num">
|
| 40 |
{String(items.length - i).padStart(2, "0")}
|
| 41 |
</span>
|
| 42 |
+
<span className="label-mono">{kindLabel} · {fmtTime(h.createdAt)}</span>
|
|
|
|
|
|
|
| 43 |
</div>
|
| 44 |
<p className="text-[13px] leading-snug line-clamp-3">{h.text}</p>
|
| 45 |
<audio controls src={url} className="w-full h-9" />
|
| 46 |
+
<div className="flex items-center justify-between">
|
| 47 |
+
{h.seedUsed != null ? (
|
| 48 |
+
<button
|
| 49 |
+
type="button"
|
| 50 |
+
onClick={() => onReuseSeed?.(h.seedUsed!)}
|
| 51 |
+
className="label-mono hover:text-[hsl(var(--ember))] transition-colors"
|
| 52 |
+
title="Copy this seed into the active params"
|
| 53 |
+
>
|
| 54 |
+
seed {h.seedUsed} · ↻
|
| 55 |
+
</button>
|
| 56 |
+
) : (
|
| 57 |
+
<span className="label-mono text-muted-foreground/60">no seed</span>
|
| 58 |
+
)}
|
| 59 |
+
<div className="flex gap-3">
|
| 60 |
+
<a
|
| 61 |
+
href={url}
|
| 62 |
+
download={`${h.id}.wav`}
|
| 63 |
+
className="label-mono hover:text-foreground transition-colors"
|
| 64 |
+
>
|
| 65 |
+
↓ download
|
| 66 |
+
</a>
|
| 67 |
+
<button
|
| 68 |
+
type="button"
|
| 69 |
+
className="label-mono hover:text-[hsl(var(--ember))] transition-colors"
|
| 70 |
+
onClick={() => onRegenerate(h)}
|
| 71 |
+
>
|
| 72 |
+
↻ regenerate
|
| 73 |
+
</button>
|
| 74 |
+
</div>
|
| 75 |
</div>
|
| 76 |
</li>
|
| 77 |
);
|
web/src/lib/api.ts
CHANGED
|
@@ -57,7 +57,12 @@ export type GenerateInput = {
|
|
| 57 |
reference?: Blob;
|
| 58 |
};
|
| 59 |
|
| 60 |
-
export
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
const fd = new FormData();
|
| 62 |
fd.set("text", input.text);
|
| 63 |
fd.set("model_id", input.modelId);
|
|
@@ -71,7 +76,10 @@ export async function generate(input: GenerateInput): Promise<Blob> {
|
|
| 71 |
const msg = err?.error?.message;
|
| 72 |
throw new Error(msg ? `${code}: ${msg}` : code);
|
| 73 |
}
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
| 75 |
}
|
| 76 |
|
| 77 |
export function streamActiveEvents(
|
|
|
|
| 57 |
reference?: Blob;
|
| 58 |
};
|
| 59 |
|
| 60 |
+
export type GenerateResult = {
|
| 61 |
+
blob: Blob;
|
| 62 |
+
seedUsed: number | null;
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
export async function generate(input: GenerateInput): Promise<GenerateResult> {
|
| 66 |
const fd = new FormData();
|
| 67 |
fd.set("text", input.text);
|
| 68 |
fd.set("model_id", input.modelId);
|
|
|
|
| 76 |
const msg = err?.error?.message;
|
| 77 |
throw new Error(msg ? `${code}: ${msg}` : code);
|
| 78 |
}
|
| 79 |
+
const seedHeader = r.headers.get("x-seed-used");
|
| 80 |
+
const seedUsed = seedHeader != null ? Number(seedHeader) : null;
|
| 81 |
+
const blob = await r.blob();
|
| 82 |
+
return { blob, seedUsed };
|
| 83 |
}
|
| 84 |
|
| 85 |
export function streamActiveEvents(
|
web/src/lib/idb.ts
CHANGED
|
@@ -12,6 +12,8 @@ export type VoiceRecord = {
|
|
| 12 |
isFavorite: boolean;
|
| 13 |
};
|
| 14 |
|
|
|
|
|
|
|
| 15 |
export type HistoryRecord = {
|
| 16 |
id?: number;
|
| 17 |
text: string;
|
|
@@ -21,6 +23,9 @@ export type HistoryRecord = {
|
|
| 21 |
params: Record<string, unknown>;
|
| 22 |
audioBlob: Blob;
|
| 23 |
createdAt: number;
|
|
|
|
|
|
|
|
|
|
| 24 |
};
|
| 25 |
|
| 26 |
class DB extends Dexie {
|
|
@@ -33,6 +38,15 @@ class DB extends Dexie {
|
|
| 33 |
voices: "++id, name, createdAt, isFavorite",
|
| 34 |
history: "++id, createdAt",
|
| 35 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
}
|
| 38 |
|
|
|
|
| 12 |
isFavorite: boolean;
|
| 13 |
};
|
| 14 |
|
| 15 |
+
export type SpeakerRef = { letter: "A" | "B" | "C" | "D"; voiceId: number };
|
| 16 |
+
|
| 17 |
export type HistoryRecord = {
|
| 18 |
id?: number;
|
| 19 |
text: string;
|
|
|
|
| 23 |
params: Record<string, unknown>;
|
| 24 |
audioBlob: Blob;
|
| 25 |
createdAt: number;
|
| 26 |
+
kind?: "single" | "dialog";
|
| 27 |
+
seedUsed?: number;
|
| 28 |
+
speakers?: SpeakerRef[];
|
| 29 |
};
|
| 30 |
|
| 31 |
class DB extends Dexie {
|
|
|
|
| 38 |
voices: "++id, name, createdAt, isFavorite",
|
| 39 |
history: "++id, createdAt",
|
| 40 |
});
|
| 41 |
+
this.version(2).stores({
|
| 42 |
+
voices: "++id, name, createdAt, isFavorite",
|
| 43 |
+
history: "++id, createdAt",
|
| 44 |
+
}).upgrade(async (tx) => {
|
| 45 |
+
// Backfill new fields on existing rows so listings stay consistent.
|
| 46 |
+
await tx.table("history").toCollection().modify((r: HistoryRecord) => {
|
| 47 |
+
if (!r.kind) r.kind = "single";
|
| 48 |
+
});
|
| 49 |
+
});
|
| 50 |
}
|
| 51 |
}
|
| 52 |
|
web/src/pages/Studio.tsx
CHANGED
|
@@ -103,7 +103,7 @@ export default function Studio() {
|
|
| 103 |
const inputText = reuse?.text ?? text;
|
| 104 |
const inputLang = reuse?.language ?? language;
|
| 105 |
const inputParams = reuse?.params ?? params;
|
| 106 |
-
const
|
| 107 |
modelId: active.id,
|
| 108 |
text: inputText,
|
| 109 |
language: inputLang,
|
|
@@ -112,7 +112,7 @@ export default function Studio() {
|
|
| 112 |
});
|
| 113 |
setOutputUrl((u) => {
|
| 114 |
if (u) URL.revokeObjectURL(u);
|
| 115 |
-
return URL.createObjectURL(
|
| 116 |
});
|
| 117 |
await addHistory({
|
| 118 |
text: inputText,
|
|
@@ -120,7 +120,9 @@ export default function Studio() {
|
|
| 120 |
voiceId: selectedVoice?.id,
|
| 121 |
language: inputLang,
|
| 122 |
params: inputParams,
|
| 123 |
-
audioBlob:
|
|
|
|
|
|
|
| 124 |
});
|
| 125 |
setHistoryKey((k) => k + 1);
|
| 126 |
} catch (e) {
|
|
@@ -275,7 +277,11 @@ export default function Studio() {
|
|
| 275 |
refreshKey={libraryKey}
|
| 276 |
/>
|
| 277 |
) : (
|
| 278 |
-
<HistoryList
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
)}
|
| 280 |
</aside>
|
| 281 |
</main>
|
|
|
|
| 103 |
const inputText = reuse?.text ?? text;
|
| 104 |
const inputLang = reuse?.language ?? language;
|
| 105 |
const inputParams = reuse?.params ?? params;
|
| 106 |
+
const result = await generate({
|
| 107 |
modelId: active.id,
|
| 108 |
text: inputText,
|
| 109 |
language: inputLang,
|
|
|
|
| 112 |
});
|
| 113 |
setOutputUrl((u) => {
|
| 114 |
if (u) URL.revokeObjectURL(u);
|
| 115 |
+
return URL.createObjectURL(result.blob);
|
| 116 |
});
|
| 117 |
await addHistory({
|
| 118 |
text: inputText,
|
|
|
|
| 120 |
voiceId: selectedVoice?.id,
|
| 121 |
language: inputLang,
|
| 122 |
params: inputParams,
|
| 123 |
+
audioBlob: result.blob,
|
| 124 |
+
kind: "single",
|
| 125 |
+
seedUsed: result.seedUsed ?? undefined,
|
| 126 |
});
|
| 127 |
setHistoryKey((k) => k + 1);
|
| 128 |
} catch (e) {
|
|
|
|
| 277 |
refreshKey={libraryKey}
|
| 278 |
/>
|
| 279 |
) : (
|
| 280 |
+
<HistoryList
|
| 281 |
+
refreshKey={historyKey}
|
| 282 |
+
onRegenerate={onGenerate}
|
| 283 |
+
onReuseSeed={(seed) => setParams((p) => ({ ...p, seed }))}
|
| 284 |
+
/>
|
| 285 |
)}
|
| 286 |
</aside>
|
| 287 |
</main>
|
web/src/test/api.test.ts
CHANGED
|
@@ -37,22 +37,20 @@ describe("api", () => {
|
|
| 37 |
);
|
| 38 |
});
|
| 39 |
|
| 40 |
-
it("generate posts multipart and returns response blob", async () => {
|
| 41 |
-
fetchMock.mockResolvedValue(
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
| 48 |
expect(fetchMock).toHaveBeenCalledWith(
|
| 49 |
"/api/generate",
|
| 50 |
expect.objectContaining({ method: "POST" }),
|
| 51 |
);
|
| 52 |
-
const call = fetchMock.mock.calls[0];
|
| 53 |
-
const body = call[1].body as FormData;
|
| 54 |
-
expect(body.get("text")).toBe("hi");
|
| 55 |
-
expect(body.get("model_id")).toBe("x");
|
| 56 |
});
|
| 57 |
|
| 58 |
it("generate surfaces error JSON on 4xx", async () => {
|
|
|
|
| 37 |
);
|
| 38 |
});
|
| 39 |
|
| 40 |
+
it("generate posts multipart and returns response blob with seed", async () => {
|
| 41 |
+
fetchMock.mockResolvedValue(
|
| 42 |
+
new Response("RIFFFAKE", {
|
| 43 |
+
status: 200,
|
| 44 |
+
headers: { "X-Seed-Used": "777" },
|
| 45 |
+
}),
|
| 46 |
+
);
|
| 47 |
+
const out = await generate({ modelId: "x", text: "hi", params: {} });
|
| 48 |
+
expect(typeof out.blob.size).toBe("number");
|
| 49 |
+
expect(out.seedUsed).toBe(777);
|
| 50 |
expect(fetchMock).toHaveBeenCalledWith(
|
| 51 |
"/api/generate",
|
| 52 |
expect.objectContaining({ method: "POST" }),
|
| 53 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
});
|
| 55 |
|
| 56 |
it("generate surfaces error JSON on 4xx", async () => {
|
web/src/test/idb.test.ts
CHANGED
|
@@ -55,3 +55,45 @@ describe("history", () => {
|
|
| 55 |
expect(items[0].text).toBe(`t${HISTORY_CAP + 4}`);
|
| 56 |
});
|
| 57 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
expect(items[0].text).toBe(`t${HISTORY_CAP + 4}`);
|
| 56 |
});
|
| 57 |
});
|
| 58 |
+
|
| 59 |
+
describe("history v2", () => {
|
| 60 |
+
it("stores seedUsed and kind on a row", async () => {
|
| 61 |
+
const id = await addHistory({
|
| 62 |
+
text: "x",
|
| 63 |
+
modelId: "m",
|
| 64 |
+
voiceId: undefined,
|
| 65 |
+
language: undefined,
|
| 66 |
+
params: {},
|
| 67 |
+
audioBlob: new Blob([""]),
|
| 68 |
+
kind: "single",
|
| 69 |
+
seedUsed: 12345,
|
| 70 |
+
});
|
| 71 |
+
const items = await listHistory();
|
| 72 |
+
const item = items.find((h) => h.id === id)!;
|
| 73 |
+
expect(item.seedUsed).toBe(12345);
|
| 74 |
+
expect(item.kind).toBe("single");
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
it("stores speakers list on a dialog row", async () => {
|
| 78 |
+
const id = await addHistory({
|
| 79 |
+
text: "SPEAKER A: hi",
|
| 80 |
+
modelId: "m",
|
| 81 |
+
voiceId: undefined,
|
| 82 |
+
language: undefined,
|
| 83 |
+
params: {},
|
| 84 |
+
audioBlob: new Blob([""]),
|
| 85 |
+
kind: "dialog",
|
| 86 |
+
seedUsed: 7,
|
| 87 |
+
speakers: [
|
| 88 |
+
{ letter: "A", voiceId: 1 },
|
| 89 |
+
{ letter: "B", voiceId: 2 },
|
| 90 |
+
],
|
| 91 |
+
});
|
| 92 |
+
const items = await listHistory();
|
| 93 |
+
const item = items.find((h) => h.id === id)!;
|
| 94 |
+
expect(item.speakers).toEqual([
|
| 95 |
+
{ letter: "A", voiceId: 1 },
|
| 96 |
+
{ letter: "B", voiceId: 2 },
|
| 97 |
+
]);
|
| 98 |
+
});
|
| 99 |
+
});
|