feat(web): progress subscriber + useProgress hook
Browse files- web/src/lib/progress.ts +75 -0
- web/src/test/progress.test.ts +77 -0
web/src/lib/progress.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type ProgressState =
|
| 2 |
+
| { phase: "idle" }
|
| 3 |
+
| {
|
| 4 |
+
phase: "running";
|
| 5 |
+
kind: "single" | "dialog";
|
| 6 |
+
turn: number;
|
| 7 |
+
total: number;
|
| 8 |
+
elapsedS: number;
|
| 9 |
+
}
|
| 10 |
+
| { phase: "done"; elapsedS: number }
|
| 11 |
+
| { phase: "error"; message: string };
|
| 12 |
+
|
| 13 |
+
type ProgressEvent = {
|
| 14 |
+
type: "start" | "tick" | "turn_complete" | "done" | "error";
|
| 15 |
+
elapsed_s: number;
|
| 16 |
+
kind?: "single" | "dialog";
|
| 17 |
+
turn?: number;
|
| 18 |
+
total_turns?: number;
|
| 19 |
+
message?: string;
|
| 20 |
+
seed_used?: number | null;
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
export function subscribeProgress(
|
| 24 |
+
onState: (s: ProgressState) => void,
|
| 25 |
+
): () => void {
|
| 26 |
+
const es = new EventSource("/api/progress");
|
| 27 |
+
let doneTimer: number | null = null;
|
| 28 |
+
|
| 29 |
+
es.onmessage = (m: MessageEvent) => {
|
| 30 |
+
if (doneTimer !== null) {
|
| 31 |
+
window.clearTimeout(doneTimer);
|
| 32 |
+
doneTimer = null;
|
| 33 |
+
}
|
| 34 |
+
let evt: ProgressEvent;
|
| 35 |
+
try {
|
| 36 |
+
evt = JSON.parse(m.data) as ProgressEvent;
|
| 37 |
+
} catch {
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
if (evt.type === "start" || evt.type === "tick" || evt.type === "turn_complete") {
|
| 41 |
+
onState({
|
| 42 |
+
phase: "running",
|
| 43 |
+
kind: (evt.kind ?? "single"),
|
| 44 |
+
turn: evt.turn ?? 0,
|
| 45 |
+
total: evt.total_turns ?? 1,
|
| 46 |
+
elapsedS: evt.elapsed_s ?? 0,
|
| 47 |
+
});
|
| 48 |
+
return;
|
| 49 |
+
}
|
| 50 |
+
if (evt.type === "done") {
|
| 51 |
+
onState({ phase: "done", elapsedS: evt.elapsed_s });
|
| 52 |
+
doneTimer = window.setTimeout(() => onState({ phase: "idle" }), 1000);
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
if (evt.type === "error") {
|
| 56 |
+
onState({ phase: "error", message: evt.message ?? "Generation failed" });
|
| 57 |
+
}
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
return () => {
|
| 61 |
+
if (doneTimer !== null) window.clearTimeout(doneTimer);
|
| 62 |
+
es.close();
|
| 63 |
+
};
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
import { useEffect, useState } from "react";
|
| 67 |
+
|
| 68 |
+
export function useProgress(): ProgressState {
|
| 69 |
+
const [state, setState] = useState<ProgressState>({ phase: "idle" });
|
| 70 |
+
useEffect(() => {
|
| 71 |
+
const close = subscribeProgress(setState);
|
| 72 |
+
return close;
|
| 73 |
+
}, []);
|
| 74 |
+
return state;
|
| 75 |
+
}
|
web/src/test/progress.test.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { subscribeProgress, type ProgressState } from "@/lib/progress";
|
| 3 |
+
|
| 4 |
+
class MockEventSource {
|
| 5 |
+
url: string;
|
| 6 |
+
onmessage: ((m: { data: string }) => void) | null = null;
|
| 7 |
+
closed = false;
|
| 8 |
+
static last: MockEventSource;
|
| 9 |
+
constructor(url: string) {
|
| 10 |
+
this.url = url;
|
| 11 |
+
MockEventSource.last = this;
|
| 12 |
+
}
|
| 13 |
+
emit(data: object) {
|
| 14 |
+
this.onmessage?.({ data: JSON.stringify(data) });
|
| 15 |
+
}
|
| 16 |
+
close() {
|
| 17 |
+
this.closed = true;
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
beforeEach(() => {
|
| 22 |
+
vi.stubGlobal("EventSource", MockEventSource);
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
afterEach(() => {
|
| 26 |
+
vi.unstubAllGlobals();
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
describe("subscribeProgress", () => {
|
| 30 |
+
it("emits running on start", () => {
|
| 31 |
+
const states: ProgressState[] = [];
|
| 32 |
+
subscribeProgress((s) => states.push(s));
|
| 33 |
+
MockEventSource.last.emit({
|
| 34 |
+
type: "start", elapsed_s: 0, kind: "dialog", total_turns: 3, turn: 0,
|
| 35 |
+
});
|
| 36 |
+
expect(states[0]).toMatchObject({ phase: "running", kind: "dialog", total: 3 });
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
it("updates turn on turn_complete", () => {
|
| 40 |
+
const states: ProgressState[] = [];
|
| 41 |
+
subscribeProgress((s) => states.push(s));
|
| 42 |
+
MockEventSource.last.emit({
|
| 43 |
+
type: "start", elapsed_s: 0, kind: "dialog", total_turns: 3, turn: 0,
|
| 44 |
+
});
|
| 45 |
+
MockEventSource.last.emit({
|
| 46 |
+
type: "turn_complete", elapsed_s: 1.2, kind: "dialog", total_turns: 3, turn: 2,
|
| 47 |
+
});
|
| 48 |
+
const last = states[states.length - 1];
|
| 49 |
+
expect(last).toMatchObject({ phase: "running", turn: 2, total: 3 });
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
it("transitions to done then idle", async () => {
|
| 53 |
+
vi.useFakeTimers();
|
| 54 |
+
const states: ProgressState[] = [];
|
| 55 |
+
subscribeProgress((s) => states.push(s));
|
| 56 |
+
MockEventSource.last.emit({ type: "done", elapsed_s: 4.5 });
|
| 57 |
+
expect(states[states.length - 1]).toMatchObject({ phase: "done", elapsedS: 4.5 });
|
| 58 |
+
vi.advanceTimersByTime(1100);
|
| 59 |
+
expect(states[states.length - 1]).toMatchObject({ phase: "idle" });
|
| 60 |
+
vi.useRealTimers();
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
it("emits error", () => {
|
| 64 |
+
const states: ProgressState[] = [];
|
| 65 |
+
subscribeProgress((s) => states.push(s));
|
| 66 |
+
MockEventSource.last.emit({
|
| 67 |
+
type: "error", elapsed_s: 2, message: "boom",
|
| 68 |
+
});
|
| 69 |
+
expect(states[states.length - 1]).toMatchObject({ phase: "error", message: "boom" });
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
it("close() shuts down EventSource", () => {
|
| 73 |
+
const close = subscribeProgress(() => {});
|
| 74 |
+
close();
|
| 75 |
+
expect(MockEventSource.last.closed).toBe(true);
|
| 76 |
+
});
|
| 77 |
+
});
|