buyer/pdf: durable job store + past-work list + resume-on-return

The summarization runs server-side, but the SPA held the job only in
component state and jobs lived only in memory — so navigating away from
the Book Summarizer reset the UI (losing the handle to a still-running
job) and past summaries vanished on restart.

- PdfJobDb: SQLite store (its own file under ARCHIVE_DIR) persisting
  every job on each progress update.
- JobStore: createdAt, persist-on-update, viewOf()/list() that serve
  live jobs first then fall back to the durable record; non-live jobs
  whose worker died surface as a new "interrupted" status.
- routes: GET /v1/pdf-jobs (newest-first list); GET /:id + summary.md
  serve past jobs via viewOf; /:id/events sends a single terminal frame
  for a finished/past job (no hang) and live frames otherwise.
- SPA: RecentJobs list (open any job → reconnect to live progress or
  show result), localStorage resume of the active job on return, and a
  "back to list (keeps running)" affordance.
- mainnet buyer compose: persistent named volume + ARCHIVE_DIR so the
  archive + pdf-jobs.db survive container recreation/redeploys.

Tests: persistence-across-restart, interrupted detection, list, and
past-job serving (incl. terminal SSE). Full buyer suite green (185).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
david
2026-06-03 11:59:20 +02:00
parent c552c4caa3
commit cf77811331
10 changed files with 674 additions and 50 deletions

View File

@@ -17,6 +17,7 @@ import { buildChainProvider } from "./chain.js";
import { Marketplace, MemoryTaskHistoryStore } from "./sdk/index.js";
import { ResponseArchive } from "./db/archive.js";
import { JobStore } from "./pdf/summarize-job.js";
import { PdfJobDb } from "./pdf/job-db.js";
import { loadPdfCaps } from "./pdf/caps.js";
import type { WalletKey } from "@marketplace/shared/tx";
@@ -96,6 +97,19 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
// PDF book summarizer: one job registry, sharing the live marketplace SDK,
// chain, wallet, and (optional) archive. Caps come from PDF_* env vars.
const pdfCaps = loadPdfCaps(env);
// Durable job store (SQLite under ARCHIVE_DIR) so past summaries survive
// restarts and navigation. Best-effort: if it can't open, jobs fall back to
// in-memory only (lost on restart) but the feature still works.
let pdfJobDb: PdfJobDb | undefined;
try {
pdfJobDb = new PdfJobDb(config.archiveDir);
console.log(`[buyer] pdf job store at ${config.archiveDir}/pdf-jobs.db`);
} catch (err) {
console.error(
`[buyer] failed to open pdf job store at ${config.archiveDir}:`,
err instanceof Error ? err.message : String(err),
);
}
const jobStore = new JobStore({
marketplace,
chain,
@@ -103,6 +117,7 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
indexerUrl: config.indexerUrl,
archive,
caps: pdfCaps,
db: pdfJobDb,
});
const app = createApp({

97
buyer/src/pdf/job-db.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* buyer/src/pdf/job-db.ts — durable persistence for PDF summarization jobs.
*
* Jobs are otherwise in-memory (lost on restart), so "past work" would vanish
* on every redeploy. This SQLite table (its own file under ARCHIVE_DIR, beside
* the response archive) records every job's metadata, final summary, per-chunk
* results, and escrow refs so the operator can list + reopen past summaries
* across restarts.
*
* Synchronous (better-sqlite3) like db/archive.ts — volume is low (a handful
* of writes per book) and the simplicity is worth more than async overhead.
*/
import { mkdirSync } from "fs";
import { join, resolve } from "path";
import Database from "better-sqlite3";
import type { Database as BetterDatabase } from "better-sqlite3";
export interface JobRecord {
job_id: string;
filename: string;
status: string;
page_count: number;
chunk_count: number;
coverage_done: number;
coverage_total: number;
running_cost_lovelace: string;
final_summary_md: string | null;
chunk_results_json: string;
escrow_refs_json: string;
created_at: number;
updated_at: number;
}
const CREATE_SQL = `
CREATE TABLE IF NOT EXISTS pdf_jobs (
job_id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
status TEXT NOT NULL,
page_count INTEGER NOT NULL,
chunk_count INTEGER NOT NULL,
coverage_done INTEGER NOT NULL,
coverage_total INTEGER NOT NULL,
running_cost_lovelace TEXT NOT NULL,
final_summary_md TEXT,
chunk_results_json TEXT NOT NULL,
escrow_refs_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_pdf_jobs_created ON pdf_jobs (created_at DESC);
`;
export class PdfJobDb {
private readonly db: BetterDatabase;
private readonly upsertStmt;
private readonly listStmt;
private readonly getStmt;
constructor(archiveDir: string) {
const dir = resolve(archiveDir);
mkdirSync(dir, { recursive: true });
this.db = new Database(join(dir, "pdf-jobs.db"));
this.db.pragma("journal_mode = WAL");
this.db.exec(CREATE_SQL);
this.upsertStmt = this.db.prepare(`
INSERT OR REPLACE INTO pdf_jobs (
job_id, filename, status, page_count, chunk_count, coverage_done,
coverage_total, running_cost_lovelace, final_summary_md,
chunk_results_json, escrow_refs_json, created_at, updated_at
) VALUES (
@job_id, @filename, @status, @page_count, @chunk_count, @coverage_done,
@coverage_total, @running_cost_lovelace, @final_summary_md,
@chunk_results_json, @escrow_refs_json, @created_at, @updated_at
)
`);
this.listStmt = this.db.prepare(`SELECT * FROM pdf_jobs ORDER BY created_at DESC LIMIT @limit`);
this.getStmt = this.db.prepare(`SELECT * FROM pdf_jobs WHERE job_id = @job_id`);
}
upsert(rec: JobRecord): void {
this.upsertStmt.run(rec as unknown as Record<string, unknown>);
}
list(limit = 100): JobRecord[] {
return this.listStmt.all({ limit }) as JobRecord[];
}
get(jobId: string): JobRecord | null {
return (this.getStmt.get({ job_id: jobId }) as JobRecord | undefined) ?? null;
}
close(): void {
this.db.close();
}
}

View File

@@ -113,6 +113,11 @@ export function registerPdfRoutes(
});
});
// ── GET /v1/pdf-jobs — past-work list (newest first) ────────────────
app.get("/v1/pdf-jobs", (_req: Request, res: Response) => {
return res.status(200).json({ jobs: jobStore.list(100) });
});
// ── GET /v1/pdf-jobs/:id/estimate ───────────────────────────────────
app.get("/v1/pdf-jobs/:id/estimate", async (req: Request, res: Response) => {
const job = jobStore.get(String(req.params.id));
@@ -163,10 +168,14 @@ export function registerPdfRoutes(
});
// ── GET /v1/pdf-jobs/:id/events (SSE) ───────────────────────────────
// Live jobs stream real frames + replay; a finished/past (non-live) job
// emits a single terminal `done` frame so the client resolves immediately.
app.get("/v1/pdf-jobs/:id/events", (req: Request, res: Response) => {
const job = jobStore.get(String(req.params.id));
if (!job) {
jsonError(res, 404, "job_not_found", `no job ${String(req.params.id)}`);
const id = String(req.params.id);
const job = jobStore.get(id);
const view = jobStore.viewOf(id);
if (!job && !view) {
jsonError(res, 404, "job_not_found", `no job ${id}`);
return;
}
res.setHeader("Content-Type", "text/event-stream");
@@ -178,7 +187,23 @@ export function registerPdfRoutes(
(res as Response & { flushHeaders: () => void }).flushHeaders();
}
const unsubscribe = job.subscribe((frame) => res.write(frame));
if (!job && view) {
// Past/interrupted job: synthesize one terminal frame and end.
const frame = {
phase: "done",
completed: view.coverage.done,
failed: 0,
total: view.coverage.total,
running_cost_lovelace: view.running_cost_lovelace,
coverage: view.coverage,
status: view.status,
};
res.write(`event: done\ndata: ${JSON.stringify(frame)}\n\n`);
res.end();
return;
}
const unsubscribe = job!.subscribe((frame) => res.write(frame));
const keepAlive = setInterval(() => res.write(": keep-alive\n\n"), 15_000);
req.on("close", () => {
clearInterval(keepAlive);
@@ -188,21 +213,21 @@ export function registerPdfRoutes(
// ── GET /v1/pdf-jobs/:id/summary.md ─────────────────────────────────
app.get("/v1/pdf-jobs/:id/summary.md", (req: Request, res: Response) => {
const job = jobStore.get(String(req.params.id));
if (!job) return jsonError(res, 404, "job_not_found", `no job ${String(req.params.id)}`);
if (!job.finalSummary) {
const view = jobStore.viewOf(String(req.params.id));
if (!view) return jsonError(res, 404, "job_not_found", `no job ${String(req.params.id)}`);
if (!view.final_summary_md) {
return jsonError(res, 404, "not_ready", "summary not available yet");
}
const safeName = job.filename.replace(/\.pdf$/i, "").replace(/[^A-Za-z0-9._-]+/g, "_") || "summary";
const safeName = view.filename.replace(/\.pdf$/i, "").replace(/[^A-Za-z0-9._-]+/g, "_") || "summary";
res.setHeader("Content-Type", "text/markdown; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="${safeName}-summary.md"`);
return res.send(job.finalSummary);
return res.send(view.final_summary_md);
});
// ── GET /v1/pdf-jobs/:id ────────────────────────────────────────────
app.get("/v1/pdf-jobs/:id", (req: Request, res: Response) => {
const job = jobStore.get(String(req.params.id));
if (!job) return jsonError(res, 404, "job_not_found", `no job ${String(req.params.id)}`);
return res.status(200).json(job.view());
const view = jobStore.viewOf(String(req.params.id));
if (!view) return jsonError(res, 404, "job_not_found", `no job ${String(req.params.id)}`);
return res.status(200).json(view);
});
}

View File

@@ -36,7 +36,16 @@ import type { ResponseArchive } from "../db/archive.js";
import { runAccept } from "../cli/acceptFlow.js";
import { SUMMARIZE_CAPABILITY_ID, buildSupplierPool, SupplierPool } from "./supplier-pool.js";
import { estimateJob, feeHeadroomLovelace, type JobEstimate } from "./estimate.js";
import type { Chunk, ChunkResult, JobPhase, JobView, PdfCaps, PoolSupplier } from "./types.js";
import type {
Chunk,
ChunkResult,
JobListItem,
JobPhase,
JobView,
PdfCaps,
PoolSupplier,
} from "./types.js";
import type { PdfJobDb, JobRecord } from "./job-db.js";
const ESCROW_REF_RE = /^([0-9a-f]{64})#(\d+)$/;
const SUBMITTED_LOOKUP_TIMEOUT_MS = 30_000;
@@ -62,11 +71,16 @@ export interface JobDeps {
indexerUrl: string;
archive?: ResponseArchive;
caps: PdfCaps;
/** Durable job store. When present, every job is persisted on each update
* so past work survives restarts and is listable/openable. */
db?: PdfJobDb;
/** Override the per-call lifecycle (tests). Defaults to the real
* submitPrompt → findSubmittedEscrowRef → runAccept sequence. */
runCall?: RunCallFn;
/** Override the wallet-balance read (tests). Defaults to chain query. */
walletBalance?: () => Promise<bigint>;
/** Injectable clock (tests). Defaults to Date.now. */
now?: () => number;
}
export interface ProgressEvent {
@@ -193,11 +207,20 @@ async function runWithConcurrency<T>(
await Promise.all(lanes);
}
function safeParse<T>(s: string, fallback: T): T {
try {
return JSON.parse(s) as T;
} catch {
return fallback;
}
}
export class Job {
readonly jobId: string;
readonly filename: string;
readonly pageCount: number;
readonly chunks: Chunk[];
readonly createdAt: number;
status: JobPhase = "estimated";
runningCost = 0n;
finalSummary?: string;
@@ -206,15 +229,19 @@ export class Job {
readonly escrowRefs: string[] = [];
/** Map results indexed by chunk index; reduce results appended after. */
readonly chunkResults: ChunkResult[];
/** Persistence hook — JobStore wires this to write the job to SQLite on
* every progress emit, so an interrupted/navigated-away job is recoverable. */
onUpdate?: () => void;
private subscribers = new Set<(frame: string) => void>();
private frames: string[] = [];
constructor(filename: string, pageCount: number, chunks: Chunk[]) {
constructor(filename: string, pageCount: number, chunks: Chunk[], createdAt: number) {
this.jobId = `job_${randomUUID()}`;
this.filename = filename;
this.pageCount = pageCount;
this.chunks = chunks;
this.createdAt = createdAt;
this.chunkResults = chunks.map((c) => ({
index: c.index,
label: `map:${c.index}`,
@@ -238,6 +265,8 @@ export class Job {
/* dropped client; route's close handler unsubscribes */
}
}
// Persist after fanning out so the durable record reflects the latest frame.
this.onUpdate?.();
}
/** Attach an SSE writer; replays buffered frames so reconnects catch up. */
@@ -261,6 +290,7 @@ export class Job {
final_summary_md: this.finalSummary,
chunk_results: this.chunkResults,
escrow_refs: this.escrowRefs,
created_at: this.createdAt,
};
}
}
@@ -272,16 +302,111 @@ export class JobStore {
constructor(private readonly deps: JobDeps) {}
private now(): number {
return this.deps.now ? this.deps.now() : Date.now();
}
createJob(filename: string, pageCount: number, chunks: Chunk[]): Job {
const job = new Job(filename, pageCount, chunks);
const job = new Job(filename, pageCount, chunks, this.now());
job.onUpdate = () => this.persist(job);
this.jobs.set(job.jobId, job);
this.persist(job);
return job;
}
/** The live (in-memory) job — needed for estimate/start/SSE. */
get(id: string): Job | undefined {
return this.jobs.get(id);
}
/** Write the job's current state to the durable store (no-op without db). */
private persist(job: Job): void {
if (!this.deps.db) return;
this.deps.db.upsert(this.recordOf(job));
}
private recordOf(job: Job): JobRecord {
return {
job_id: job.jobId,
filename: job.filename,
status: job.status,
page_count: job.pageCount,
chunk_count: job.chunks.length,
coverage_done: job.coverageDone,
coverage_total: job.coverageTotal,
running_cost_lovelace: job.runningCost.toString(),
final_summary_md: job.finalSummary ?? null,
chunk_results_json: JSON.stringify(job.chunkResults),
escrow_refs_json: JSON.stringify(job.escrowRefs),
created_at: job.createdAt,
updated_at: this.now(),
};
}
/** A non-live persisted record whose status is non-terminal means the
* worker died mid-flight — surface it as "interrupted". */
private staleStatus(r: JobRecord): JobPhase {
const s = r.status as JobPhase;
return s === "estimated" || s === "running" ? "interrupted" : s;
}
private recordToView(r: JobRecord): JobView {
return {
job_id: r.job_id,
filename: r.filename,
status: this.staleStatus(r),
page_count: r.page_count,
chunk_count: r.chunk_count,
coverage: { done: r.coverage_done, total: r.coverage_total },
running_cost_lovelace: r.running_cost_lovelace,
final_summary_md: r.final_summary_md ?? undefined,
chunk_results: safeParse<ChunkResult[]>(r.chunk_results_json, []),
escrow_refs: safeParse<string[]>(r.escrow_refs_json, []),
created_at: r.created_at,
};
}
/** View for any job — live first, else the durable record (past work). */
viewOf(id: string): JobView | null {
const live = this.jobs.get(id);
if (live) return live.view();
const r = this.deps.db?.get(id);
return r ? this.recordToView(r) : null;
}
/** Newest-first list of all jobs (live + persisted), for the past-work UI. */
list(limit = 100): JobListItem[] {
const byId = new Map<string, JobListItem>();
if (this.deps.db) {
for (const r of this.deps.db.list(limit)) {
byId.set(r.job_id, {
job_id: r.job_id,
filename: r.filename,
status: this.staleStatus(r),
coverage: { done: r.coverage_done, total: r.coverage_total },
chunk_count: r.chunk_count,
running_cost_lovelace: r.running_cost_lovelace,
created_at: r.created_at,
has_summary: !!(r.final_summary_md && r.final_summary_md.length > 0),
});
}
}
// Live jobs win (most current status), even over their own db row.
for (const job of this.jobs.values()) {
byId.set(job.jobId, {
job_id: job.jobId,
filename: job.filename,
status: job.status,
coverage: { done: job.coverageDone, total: job.coverageTotal },
chunk_count: job.chunks.length,
running_cost_lovelace: job.runningCost.toString(),
created_at: job.createdAt,
has_summary: !!(job.finalSummary && job.finalSummary.length > 0),
});
}
return [...byId.values()].sort((a, b) => b.created_at - a.created_at).slice(0, limit);
}
/** Discover suppliers + read balance to produce the pre-spend estimate. */
async estimate(job: Job): Promise<EstimateView> {
const pool = await buildSupplierPool(this.deps.marketplace, this.deps.caps);
@@ -309,6 +434,7 @@ export class JobStore {
if (this.started.has(job.jobId)) return;
this.started.add(job.jobId);
job.status = "running";
this.persist(job);
void this.run(job).catch((err) => {
job.status = "failed";
// Terminal: emit a `done` frame so SSE clients stop waiting; carries the

View File

@@ -64,7 +64,11 @@ export type JobPhase =
| "running"
| "completed"
| "completed_with_gaps"
| "failed";
| "failed"
// A job that was mid-flight when the process died (persisted as running but
// no live worker on the next boot). Its partial summary/coverage is still
// openable; it cannot be resumed.
| "interrupted";
/** Per-call result row, surfaced to the UI. */
export interface ChunkResult {
@@ -90,6 +94,19 @@ export interface JobView {
final_summary_md?: string;
chunk_results: ChunkResult[];
escrow_refs: string[];
created_at: number;
}
/** Compact row returned by GET /v1/pdf-jobs (the past-work list). */
export interface JobListItem {
job_id: string;
filename: string;
status: JobPhase;
coverage: { done: number; total: number };
chunk_count: number;
running_cost_lovelace: string;
created_at: number;
has_summary: boolean;
}
/** Thrown by extractPdfText for unusable PDFs (e.g. scanned/image-only). */

View File

@@ -0,0 +1,106 @@
/**
* buyer/src/ui/components/RecentJobs.tsx — the past-work list.
*
* Fetches GET /v1/pdf-jobs and renders every job (running + finished) newest
* first. Clicking one calls onOpen(jobId); the page reconnects to a running
* job's live progress or shows a finished job's result. Re-fetches whenever
* `refreshKey` changes (e.g. after a job starts/finishes).
*/
import { useEffect, useState } from "react";
interface JobListItem {
job_id: string;
filename: string;
status: string;
coverage: { done: number; total: number };
chunk_count: number;
running_cost_lovelace: string;
created_at: number;
has_summary: boolean;
}
function ap3x(lovelace: string): string {
return (Number(lovelace) / 1e6).toFixed(2);
}
function ago(ms: number): string {
const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
function badge(status: string): { label: string; cls: string } {
switch (status) {
case "running":
return { label: "running", cls: "bg-blue-100 text-blue-800" };
case "completed":
return { label: "completed", cls: "bg-green-100 text-green-800" };
case "completed_with_gaps":
return { label: "gaps", cls: "bg-amber-100 text-amber-800" };
case "failed":
return { label: "failed", cls: "bg-red-100 text-red-700" };
case "interrupted":
return { label: "interrupted", cls: "bg-gray-200 text-gray-700" };
default:
return { label: status, cls: "bg-gray-100 text-gray-600" };
}
}
export default function RecentJobs({
refreshKey,
onOpen,
}: {
refreshKey: number;
onOpen: (jobId: string) => void;
}) {
const [jobs, setJobs] = useState<JobListItem[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetch("/v1/pdf-jobs", { credentials: "same-origin" })
.then(async (r) => {
const body = await r.json();
if (!r.ok) throw new Error(body.message ?? r.statusText);
if (!cancelled) setJobs(Array.isArray(body.jobs) ? body.jobs : []);
})
.catch((e: Error) => !cancelled && setError(e.message));
return () => {
cancelled = true;
};
}, [refreshKey]);
if (error) {
return <p className="text-sm text-red-600">Couldn’t load past summaries: {error}</p>;
}
if (jobs.length === 0) {
return <p className="text-sm text-gray-400">No past summaries yet.</p>;
}
return (
<ul className="divide-y divide-gray-100 rounded border border-gray-200 bg-white">
{jobs.map((j) => {
const b = badge(j.status);
return (
<li key={j.job_id}>
<button
type="button"
onClick={() => onOpen(j.job_id)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50"
>
<span className={`rounded px-2 py-0.5 text-xs font-medium ${b.cls}`}>{b.label}</span>
<span className="flex-1 truncate font-medium text-gray-800">{j.filename}</span>
<span className="text-xs text-gray-500">
{j.coverage.done}/{j.coverage.total} · {ap3x(j.running_cost_lovelace)} AP3X
</span>
<span className="w-16 text-right text-xs text-gray-400">{ago(j.created_at)}</span>
</button>
</li>
);
})}
</ul>
);
}

View File

@@ -3,18 +3,25 @@
*
* Upload → Estimate/Confirm → Live progress (SSE) → Result.
*
* The summarization runs SERVER-SIDE, so navigating away never stops it. This
* page remembers the active job (localStorage) and reconnects on return, and
* lists all past jobs (running + finished) so any can be reopened — a finished
* job shows its result; a running one reconnects to live progress.
*
* Each step talks only to the same-origin /v1/pdf-* server routes (the browser
* holds no key). One book upload fans out into many on-chain escrows — the
* traffic the marketplace wants — while producing a real book summary.
* holds no key).
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import PdfUploadForm, { type UploadInfo } from "../components/PdfUploadForm.js";
import JobEstimate from "../components/JobEstimate.js";
import JobProgress from "../components/JobProgress.js";
import RecentJobs from "../components/RecentJobs.js";
type Step = "upload" | "estimate" | "running" | "done";
const ACTIVE_JOB_KEY = "pdf_active_job";
interface ChunkResult {
index: number;
label: string;
@@ -27,6 +34,8 @@ interface JobView {
job_id: string;
filename: string;
status: string;
page_count: number;
chunk_count: number;
coverage: { done: number; total: number };
running_cost_lovelace: string;
final_summary_md?: string;
@@ -38,30 +47,104 @@ function ap3x(lovelace: string): string {
return (Number(lovelace) / 1e6).toFixed(2);
}
function statusLabel(status: string): string {
switch (status) {
case "completed":
return "✅ Completed";
case "completed_with_gaps":
return "⚠️ Completed with gaps";
case "interrupted":
return "⏸️ Interrupted (server restarted mid-job)";
case "running":
return "⏳ Running";
default:
return "❌ Failed";
}
}
function infoFromView(v: JobView): UploadInfo {
return {
job_id: v.job_id,
filename: v.filename,
page_count: v.page_count,
chunk_count: v.chunk_count,
sample_chunk: "",
};
}
export default function BookSummarizer() {
const [step, setStep] = useState<Step>("upload");
const [info, setInfo] = useState<UploadInfo | null>(null);
const [view, setView] = useState<JobView | null>(null);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
// On mount, resume an active job if one is still running.
useEffect(() => {
const aid = localStorage.getItem(ACTIVE_JOB_KEY);
if (!aid) return;
let cancelled = false;
fetch(`/v1/pdf-jobs/${aid}`, { credentials: "same-origin" })
.then(async (r) => {
if (!r.ok) {
localStorage.removeItem(ACTIVE_JOB_KEY);
return;
}
const v = (await r.json()) as JobView;
if (cancelled) return;
if (v.status === "running" || v.status === "estimated") {
setInfo(infoFromView(v));
setStep("running");
} else {
localStorage.removeItem(ACTIVE_JOB_KEY);
}
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
const openJob = async (jobId: string): Promise<void> => {
setError(null);
try {
const r = await fetch(`/v1/pdf-jobs/${jobId}`, { credentials: "same-origin" });
const v = (await r.json()) as JobView & { message?: string };
if (!r.ok) throw new Error(v.message ?? r.statusText);
if (v.status === "running" || v.status === "estimated") {
setInfo(infoFromView(v));
localStorage.setItem(ACTIVE_JOB_KEY, v.job_id);
setStep("running");
} else {
setView(v);
setStep("done");
}
} catch (e) {
setError((e as Error).message);
setStep("done");
}
};
const loadResult = async (jobId: string): Promise<void> => {
localStorage.removeItem(ACTIVE_JOB_KEY);
try {
const r = await fetch(`/v1/pdf-jobs/${jobId}`, { credentials: "same-origin" });
const body = (await r.json()) as JobView & { message?: string };
if (!r.ok) throw new Error(body.message ?? r.statusText);
setView(body);
setStep("done");
} catch (e) {
setError((e as Error).message);
setStep("done");
}
};
const reset = (): void => {
setStep("upload");
setInfo(null);
setView(null);
setError(null);
};
const loadResult = async (jobId: string): Promise<void> => {
try {
const r = await fetch(`/v1/pdf-jobs/${jobId}`, { credentials: "same-origin" });
const body = await r.json();
if (!r.ok) throw new Error(`${body.error}: ${body.message ?? ""}`);
setView(body as JobView);
setStep("done");
} catch (e) {
setError((e as Error).message);
setStep("done");
}
setRefreshKey((k) => k + 1);
};
return (
@@ -70,20 +153,33 @@ export default function BookSummarizer() {
<h1 className="text-2xl font-semibold">Book Summarizer</h1>
<p className="mt-1 text-sm text-gray-500">
Upload a PDF → pay suppliers to summarize each chunk → get a full-book summary.
Jobs keep running if you navigate away; reopen them below.
</p>
</div>
{step === "upload" && (
<PdfUploadForm
onUploaded={(i) => {
setInfo(i);
setStep("estimate");
}}
/>
<>
<PdfUploadForm
onUploaded={(i) => {
setInfo(i);
setStep("estimate");
}}
/>
<div className="space-y-2">
<h2 className="text-sm font-medium text-gray-700">Past &amp; in-progress summaries</h2>
<RecentJobs refreshKey={refreshKey} onOpen={(id) => void openJob(id)} />
</div>
</>
)}
{step === "estimate" && info && (
<JobEstimate info={info} onStarted={() => setStep("running")} />
<JobEstimate
info={info}
onStarted={() => {
localStorage.setItem(ACTIVE_JOB_KEY, info.job_id);
setStep("running");
}}
/>
)}
{step === "running" && info && (
@@ -96,8 +192,16 @@ export default function BookSummarizer() {
/>
<p className="text-xs text-gray-400">
Each chunk is a separate on-chain escrow (PostEscrow → supplier → Accept),
so this can take a while — that’s real marketplace traffic.
so this can take a while — that’s real marketplace traffic. You can leave this
page; the job keeps running and shows up under “Past summaries”.
</p>
<button
type="button"
onClick={reset}
className="text-sm text-gray-600 hover:underline"
>
← Back to list (keeps running)
</button>
</div>
)}
@@ -111,13 +215,8 @@ export default function BookSummarizer() {
{view && (
<>
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-sm">
<span className="font-medium">
{view.status === "completed"
? "✅ Completed"
: view.status === "completed_with_gaps"
? "⚠️ Completed with gaps"
: "❌ Failed"}
</span>
<span className="font-medium">{statusLabel(view.status)}</span>
<span className="truncate text-gray-600">{view.filename}</span>
<span>
Coverage: {view.coverage.done}/{view.coverage.total} chunks
</span>
@@ -134,10 +233,12 @@ export default function BookSummarizer() {
</a>
)}
{view.final_summary_md && (
{view.final_summary_md ? (
<pre className="max-h-[28rem] overflow-auto whitespace-pre-wrap rounded border border-gray-200 bg-white p-4 text-sm text-gray-800">
{view.final_summary_md}
</pre>
) : (
<p className="text-sm text-gray-500">No summary was produced for this job.</p>
)}
<details className="text-xs text-gray-500">
@@ -155,7 +256,7 @@ export default function BookSummarizer() {
onClick={reset}
className="rounded border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
>
Summarize another book
← Back to summaries
</button>
</div>
)}