diff --git a/.gitignore b/.gitignore index 5698218f..0ce97dca 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist/ .output/ *.tsbuildinfo /books/ +apps/api/books/ .cache/ .DS_Store diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 12cd592f..884085e6 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,6 +7,7 @@ import { healthRoutes } from "./routes/health.js" import { createBookRoutes } from "./routes/books.js" import { createPipelineRoutes } from "./routes/pipeline.js" import { createPageRoutes } from "./routes/pages.js" +import { createDebugRoutes } from "./routes/debug.js" import { createPipelineService } from "./services/pipeline-service.js" import { createPipelineRunner } from "./services/pipeline-runner.js" @@ -38,5 +39,6 @@ app.route("/api", healthRoutes) app.route("/api", createBookRoutes(booksDir)) app.route("/api", createPipelineRoutes(pipelineService, booksDir, promptsDir, configPath)) app.route("/api", createPageRoutes(booksDir, promptsDir, configPath)) +app.route("/api", createDebugRoutes(pipelineService, booksDir, promptsDir, configPath)) export default app diff --git a/apps/api/src/routes/debug.test.ts b/apps/api/src/routes/debug.test.ts new file mode 100644 index 00000000..6a76de69 --- /dev/null +++ b/apps/api/src/routes/debug.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import fs from "node:fs" +import path from "node:path" +import os from "node:os" +import { Hono } from "hono" +import { createBookStorage } from "@adt/storage" +import { openBookDb } from "@adt/storage" +import { errorHandler } from "../middleware/error-handler.js" +import { createDebugRoutes } from "./debug.js" +import type { PipelineService } from "../services/pipeline-service.js" + +function makeMockPipelineService( + overrides?: Partial +): PipelineService { + return { + getStatus: () => null, + addListener: () => () => {}, + startPipeline: async () => {}, + ...overrides, + } +} + +describe("Debug routes", () => { + let tmpDir: string + let app: Hono + const label = "test-book" + + function seedLlmLogs(dbPath: string) { + const db = openBookDb(dbPath) + try { + // Insert LLM log entries with known data + const entries = [ + { + step: "text-classification", + item_id: `${label}_p1`, + data: { + promptName: "classify-text", + modelId: "gpt-4o", + cacheHit: false, + durationMs: 1200, + usage: { inputTokens: 500, outputTokens: 200 }, + validationErrors: [], + }, + }, + { + step: "text-classification", + item_id: `${label}_p2`, + data: { + promptName: "classify-text", + modelId: "gpt-4o", + cacheHit: true, + durationMs: 50, + usage: { inputTokens: 500, outputTokens: 200 }, + }, + }, + { + step: "page-sectioning", + item_id: `${label}_p1`, + data: { + promptName: "section-page", + modelId: "gpt-4o", + cacheHit: false, + durationMs: 2000, + usage: { inputTokens: 1000, outputTokens: 500 }, + validationErrors: ["Invalid section type"], + }, + }, + { + step: "metadata", + item_id: "book", + data: { + promptName: "extract-metadata", + modelId: "gpt-4o", + cacheHit: false, + durationMs: 3000, + usage: { inputTokens: 2000, outputTokens: 300 }, + }, + }, + ] + + for (const entry of entries) { + db.run( + "INSERT INTO llm_log (timestamp, step, item_id, data) VALUES (?, ?, ?, ?)", + [new Date().toISOString(), entry.step, entry.item_id, JSON.stringify(entry.data)] + ) + } + } finally { + db.close() + } + } + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "debug-routes-")) + + // Create a book with extracted pages + const storage = createBookStorage(label, tmpDir) + try { + storage.putExtractedPage({ + pageId: `${label}_p1`, + pageNumber: 1, + text: "Page one text", + pageImage: { + imageId: `${label}_p1_page`, + pngBuffer: Buffer.from("fake-png"), + hash: "abc123", + width: 800, + height: 600, + }, + images: [], + }) + storage.putExtractedPage({ + pageId: `${label}_p2`, + pageNumber: 2, + text: "Page two text", + pageImage: { + imageId: `${label}_p2_page`, + pngBuffer: Buffer.from("fake-png-2"), + hash: "def456", + width: 800, + height: 600, + }, + images: [], + }) + + // Add node_data with multiple versions + storage.putNodeData("text-classification", `${label}_p1`, { version: "v1" }) + storage.putNodeData("text-classification", `${label}_p1`, { version: "v2" }) + storage.putNodeData("text-classification", `${label}_p1`, { version: "v3" }) + } finally { + storage.close() + } + + // Seed LLM logs + const dbPath = path.join(tmpDir, label, `${label}.db`) + seedLlmLogs(dbPath) + + const pipelineService = makeMockPipelineService() + const routes = createDebugRoutes(pipelineService, tmpDir, tmpDir) + app = new Hono() + app.onError(errorHandler) + app.route("/api", routes) + }) + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + describe("GET /api/books/:label/debug/llm-logs", () => { + it("returns paginated logs with total count", async () => { + const res = await app.request(`/api/books/${label}/debug/llm-logs`) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.total).toBe(4) + expect(body.logs).toHaveLength(4) + // Newest first + expect(body.logs[0].step).toBe("metadata") + }) + + it("filters by step", async () => { + const res = await app.request( + `/api/books/${label}/debug/llm-logs?step=text-classification` + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.total).toBe(2) + expect(body.logs).toHaveLength(2) + for (const log of body.logs) { + expect(log.step).toBe("text-classification") + } + }) + + it("filters by itemId", async () => { + const res = await app.request( + `/api/books/${label}/debug/llm-logs?itemId=${label}_p1` + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.total).toBe(2) + }) + + it("respects limit and offset", async () => { + const res = await app.request( + `/api/books/${label}/debug/llm-logs?limit=2&offset=1` + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.total).toBe(4) + expect(body.logs).toHaveLength(2) + }) + + it("clamps limit to 200", async () => { + const res = await app.request( + `/api/books/${label}/debug/llm-logs?limit=999` + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.logs.length).toBeLessThanOrEqual(200) + }) + + it("returns 404 for nonexistent book", async () => { + const res = await app.request("/api/books/no-such-book/debug/llm-logs") + expect(res.status).toBe(404) + }) + }) + + describe("GET /api/books/:label/debug/stats", () => { + it("returns aggregated stats by step", async () => { + const res = await app.request(`/api/books/${label}/debug/stats`) + expect(res.status).toBe(200) + const body = await res.json() + + expect(body.steps).toBeDefined() + expect(Array.isArray(body.steps)).toBe(true) + expect(body.totals).toBeDefined() + + // Check total counts + expect(body.totals.calls).toBe(4) + expect(body.totals.cacheHits).toBe(1) + + // Check step-level data + const textStep = body.steps.find( + (s: { step: string }) => s.step === "text-classification" + ) + expect(textStep).toBeDefined() + expect(textStep.calls).toBe(2) + expect(textStep.cacheHits).toBe(1) + }) + + it("includes pipeline run timing when available", async () => { + const pipelineService = makeMockPipelineService({ + getStatus: () => ({ + label, + status: "completed", + startedAt: 1000, + completedAt: 5000, + }), + }) + const routes = createDebugRoutes(pipelineService, tmpDir, tmpDir) + const appWithTiming = new Hono() + appWithTiming.onError(errorHandler) + appWithTiming.route("/api", routes) + + const res = await appWithTiming.request(`/api/books/${label}/debug/stats`) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.pipelineRun).toBeDefined() + expect(body.pipelineRun.status).toBe("completed") + expect(body.pipelineRun.wallClockMs).toBe(4000) + }) + + it("returns 404 for nonexistent book", async () => { + const res = await app.request("/api/books/no-such-book/debug/stats") + expect(res.status).toBe(404) + }) + }) + + describe("GET /api/books/:label/debug/versions/:node/:itemId", () => { + it("returns version list without data by default", async () => { + const res = await app.request( + `/api/books/${label}/debug/versions/text-classification/${label}_p1` + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.versions).toHaveLength(3) + // Newest first + expect(body.versions[0].version).toBe(3) + expect(body.versions[0].data).toBeUndefined() + }) + + it("includes data when includeData=true", async () => { + const res = await app.request( + `/api/books/${label}/debug/versions/text-classification/${label}_p1?includeData=true` + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.versions).toHaveLength(3) + expect(body.versions[0].data).toBeDefined() + expect(body.versions[0].data.version).toBe("v3") + }) + + it("returns empty list for unknown node/item", async () => { + const res = await app.request( + `/api/books/${label}/debug/versions/unknown-node/unknown-item` + ) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.versions).toHaveLength(0) + }) + + it("returns 404 for nonexistent book", async () => { + const res = await app.request( + "/api/books/no-such-book/debug/versions/text-classification/p1" + ) + expect(res.status).toBe(404) + }) + }) +}) diff --git a/apps/api/src/routes/debug.ts b/apps/api/src/routes/debug.ts new file mode 100644 index 00000000..5a80a5a8 --- /dev/null +++ b/apps/api/src/routes/debug.ts @@ -0,0 +1,216 @@ +import fs from "node:fs" +import path from "node:path" +import { Hono } from "hono" +import { HTTPException } from "hono/http-exception" +import { parseBookLabel } from "@adt/types" +import { openBookDb } from "@adt/storage" +import type { PipelineService } from "../services/pipeline-service.js" + +function getDbPath(label: string, booksDir: string): string { + const safeLabel = parseBookLabel(label) + return path.join(path.resolve(booksDir), safeLabel, `${safeLabel}.db`) +} + +function requireDb(label: string, booksDir: string) { + const safeLabel = parseBookLabel(label) + const dbPath = getDbPath(safeLabel, booksDir) + if (!fs.existsSync(dbPath)) { + throw new HTTPException(404, { + message: `Book not found: ${safeLabel}`, + }) + } + return { safeLabel, dbPath } +} + +function parseLogsQuery(query: Record) { + const step = query.step || undefined + const itemId = query.itemId || undefined + const rawLimit = parseInt(query.limit ?? "50", 10) + const limit = Math.min(Math.max(isNaN(rawLimit) ? 50 : rawLimit, 1), 200) + const rawOffset = parseInt(query.offset ?? "0", 10) + const offset = Math.max(isNaN(rawOffset) ? 0 : rawOffset, 0) + return { step, itemId, limit, offset } +} + +export function createDebugRoutes( + pipelineService: PipelineService, + booksDir: string, + promptsDir: string, + configPath?: string +): Hono { + const app = new Hono() + + // GET /books/:label/debug/llm-logs — paginated log query + app.get("/books/:label/debug/llm-logs", (c) => { + const { label } = c.req.param() + const { dbPath } = requireDb(label, booksDir) + + const { step, itemId, limit, offset } = parseLogsQuery(c.req.query()) + + const db = openBookDb(dbPath) + try { + const conditions: string[] = [] + const params: (string | number)[] = [] + + if (step) { + conditions.push("step = ?") + params.push(step) + } + if (itemId) { + conditions.push("item_id = ?") + params.push(itemId) + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "" + + // Get total count + const countRows = db.all( + `SELECT COUNT(*) as count FROM llm_log ${where}`, + params + ) as Array<{ count: number }> + const total = countRows[0].count + + // Get paginated logs (newest first) + const logRows = db.all( + `SELECT id, timestamp, step, item_id, data FROM llm_log ${where} ORDER BY id DESC LIMIT ? OFFSET ?`, + [...params, limit, offset] + ) as Array<{ id: number; timestamp: string; step: string; item_id: string; data: string }> + + const logs = logRows.map((row) => ({ + id: row.id, + timestamp: row.timestamp, + step: row.step, + itemId: row.item_id, + data: JSON.parse(row.data), + })) + + return c.json({ logs, total }) + } finally { + db.close() + } + }) + + // GET /books/:label/debug/stats — aggregate pipeline metrics + app.get("/books/:label/debug/stats", (c) => { + const { label } = c.req.param() + const { safeLabel, dbPath } = requireDb(label, booksDir) + + const db = openBookDb(dbPath) + try { + // Aggregate stats using json_extract on the data column + const stepRows = db.all(` + SELECT + step, + COUNT(*) as calls, + SUM(CASE WHEN json_extract(data, '$.cacheHit') = 1 THEN 1 ELSE 0 END) as cacheHits, + SUM(CASE WHEN json_extract(data, '$.cacheHit') = 0 OR json_extract(data, '$.cacheHit') IS NULL THEN 1 ELSE 0 END) as cacheMisses, + COALESCE(SUM(json_extract(data, '$.usage.inputTokens')), 0) as inputTokens, + COALESCE(SUM(json_extract(data, '$.usage.outputTokens')), 0) as outputTokens, + ROUND(AVG(json_extract(data, '$.durationMs')), 0) as avgDurationMs, + SUM(CASE WHEN json_array_length(json_extract(data, '$.validationErrors')) > 0 THEN 1 ELSE 0 END) as errorCount + FROM llm_log + GROUP BY step + ORDER BY step + `) as Array<{ + step: string + calls: number + cacheHits: number + cacheMisses: number + inputTokens: number + outputTokens: number + avgDurationMs: number + errorCount: number + }> + + // Compute totals + const totals = { + calls: 0, + cacheHits: 0, + cacheMisses: 0, + inputTokens: 0, + outputTokens: 0, + errorCount: 0, + } + for (const row of stepRows) { + totals.calls += row.calls + totals.cacheHits += row.cacheHits + totals.cacheMisses += row.cacheMisses + totals.inputTokens += row.inputTokens + totals.outputTokens += row.outputTokens + totals.errorCount += row.errorCount + } + + // Pipeline run timing + const job = pipelineService.getStatus(safeLabel) + const pipelineRun = job + ? { + status: job.status, + startedAt: job.startedAt, + completedAt: job.completedAt, + wallClockMs: + job.startedAt && job.completedAt + ? job.completedAt - job.startedAt + : undefined, + } + : null + + return c.json({ steps: stepRows, totals, pipelineRun }) + } finally { + db.close() + } + }) + + // GET /books/:label/debug/config — active merged config + app.get("/books/:label/debug/config", async (c) => { + const { label } = c.req.param() + const safeLabel = parseBookLabel(label) + const bookDir = path.join(path.resolve(booksDir), safeLabel) + + if (!fs.existsSync(bookDir)) { + throw new HTTPException(404, { message: `Book not found: ${safeLabel}` }) + } + + // Dynamic import to avoid coupling at module level + const { loadBookConfig } = await import("@adt/pipeline") + const merged = loadBookConfig(safeLabel, booksDir, configPath) + const hasBookOverride = fs.existsSync(path.join(bookDir, "config.yaml")) + + return c.json({ merged, hasBookOverride }) + }) + + // GET /books/:label/debug/versions/:node/:itemId — version history + app.get("/books/:label/debug/versions/:node/:itemId", (c) => { + const { label, node, itemId } = c.req.param() + const { dbPath } = requireDb(label, booksDir) + + const includeData = c.req.query("includeData") === "true" + + const db = openBookDb(dbPath) + try { + if (includeData) { + const rows = db.all( + "SELECT version, data FROM node_data WHERE node = ? AND item_id = ? ORDER BY version DESC", + [node, itemId] + ) as Array<{ version: number; data: string }> + + const versions = rows.map((row) => ({ + version: row.version, + data: JSON.parse(row.data), + })) + return c.json({ versions }) + } else { + const rows = db.all( + "SELECT version FROM node_data WHERE node = ? AND item_id = ? ORDER BY version DESC", + [node, itemId] + ) as Array<{ version: number }> + + const versions = rows.map((row) => ({ version: row.version })) + return c.json({ versions }) + } + } finally { + db.close() + } + }) + + return app +} diff --git a/apps/api/src/services/book-service.test.ts b/apps/api/src/services/book-service.test.ts index 1bf6b206..c8ac8e56 100644 --- a/apps/api/src/services/book-service.test.ts +++ b/apps/api/src/services/book-service.test.ts @@ -96,6 +96,8 @@ describe("listBooks", () => { label: "my-book", title: "Test Title", authors: ["Alice"], + publisher: null, + languageCode: "en", pageCount: 3, hasSourcePdf: true, needsRebuild: false, @@ -112,6 +114,8 @@ describe("listBooks", () => { label: "empty-book", title: null, authors: [], + publisher: null, + languageCode: null, pageCount: 0, hasSourcePdf: false, needsRebuild: false, @@ -130,6 +134,8 @@ describe("listBooks", () => { label: "no-db", title: null, authors: [], + publisher: null, + languageCode: null, pageCount: 0, hasSourcePdf: true, needsRebuild: false, diff --git a/apps/api/src/services/book-service.ts b/apps/api/src/services/book-service.ts index 719ff518..d050fc2a 100644 --- a/apps/api/src/services/book-service.ts +++ b/apps/api/src/services/book-service.ts @@ -8,6 +8,8 @@ export interface BookSummary { label: string title: string | null authors: string[] + publisher: string | null + languageCode: string | null pageCount: number hasSourcePdf: boolean needsRebuild: boolean @@ -40,6 +42,8 @@ export function listBooks(booksDir: string): BookSummary[] { let title: string | null = null let authors: string[] = [] + let publisher: string | null = null + let languageCode: string | null = null let pageCount = 0 let needsRebuild = false let rebuildReason: string | null = null @@ -63,6 +67,8 @@ export function listBooks(booksDir: string): BookSummary[] { if (parsed.success) { title = parsed.data.title authors = parsed.data.authors + publisher = parsed.data.publisher + languageCode = parsed.data.language_code } } } finally { @@ -83,6 +89,8 @@ export function listBooks(booksDir: string): BookSummary[] { label, title, authors, + publisher, + languageCode, pageCount, hasSourcePdf: fs.existsSync(pdfPath), needsRebuild, @@ -108,6 +116,8 @@ export function getBook(label: string, booksDir: string): BookDetail { let title: string | null = null let authors: string[] = [] + let publisher: string | null = null + let languageCode: string | null = null let pageCount = 0 let metadata: BookMetadata | null = null let needsRebuild = false @@ -133,6 +143,8 @@ export function getBook(label: string, booksDir: string): BookDetail { metadata = parsed.data title = parsed.data.title authors = parsed.data.authors + publisher = parsed.data.publisher + languageCode = parsed.data.language_code } } } finally { @@ -153,6 +165,8 @@ export function getBook(label: string, booksDir: string): BookDetail { label: safeLabel, title, authors, + publisher, + languageCode, pageCount, hasSourcePdf: fs.existsSync(pdfPath), needsRebuild, @@ -189,6 +203,8 @@ export function createBook( label: safeLabel, title: null, authors: [], + publisher: null, + languageCode: null, pageCount: 0, hasSourcePdf: true, needsRebuild: false, diff --git a/apps/api/src/services/pipeline-runner.ts b/apps/api/src/services/pipeline-runner.ts index 6aa1d4af..32904201 100644 --- a/apps/api/src/services/pipeline-runner.ts +++ b/apps/api/src/services/pipeline-runner.ts @@ -20,6 +20,7 @@ import type { TextClassificationOutput, ImageClassificationOutput, PageSectioningOutput, + StepName, } from "@adt/types" import type { PageData } from "@adt/storage" import type { @@ -100,7 +101,21 @@ export function createPipelineRunner(): PipelineRunner { cacheDir, promptEngine, rateLimiter, - onLog: (entry) => storage.appendLlmLog(entry), + onLog: (entry) => { + storage.appendLlmLog(entry) + progress.emit({ + type: "llm-log", + step: "metadata", + itemId: entry.pageId ?? "", + promptName: entry.promptName, + modelId: entry.modelId, + cacheHit: entry.cacheHit, + durationMs: entry.durationMs, + inputTokens: entry.usage?.inputTokens, + outputTokens: entry.usage?.outputTokens, + validationErrors: entry.validationErrors, + }) + }, }) const pages = storage.getPages() @@ -131,7 +146,22 @@ export function createPipelineRunner(): PipelineRunner { cacheDir, promptEngine, rateLimiter, - onLog: (entry) => storage.appendLlmLog(entry), + onLog: (entry) => { + storage.appendLlmLog(entry) + const step = entry.taskType as StepName + progress.emit({ + type: "llm-log", + step, + itemId: entry.pageId ?? "", + promptName: entry.promptName, + modelId: entry.modelId, + cacheHit: entry.cacheHit, + durationMs: entry.durationMs, + inputTokens: entry.usage?.inputTokens, + outputTokens: entry.usage?.outputTokens, + validationErrors: entry.validationErrors, + }) + }, }) const effectiveConcurrency = diff --git a/apps/studio/src/api/client.ts b/apps/studio/src/api/client.ts index d39008b6..47101c2f 100644 --- a/apps/studio/src/api/client.ts +++ b/apps/studio/src/api/client.ts @@ -24,6 +24,8 @@ export interface BookSummary { label: string title: string | null authors: string[] + publisher: string | null + languageCode: string | null pageCount: number hasSourcePdf: boolean needsRebuild: boolean @@ -104,6 +106,86 @@ export interface PageDetail { } | null } +// --- Debug types --- + +export interface LlmLogEntry { + id: number + timestamp: string + step: string + itemId: string + data: { + promptName: string + modelId: string + cacheHit: boolean + durationMs: number + usage?: { inputTokens: number; outputTokens: number } + validationErrors?: string[] + system?: string + messages: Array<{ + role: string + content: Array< + | { type: "text"; text: string } + | { type: "image"; hash: string; byteLength: number; width: number; height: number } + > + }> + } +} + +export interface LlmLogsResponse { + logs: LlmLogEntry[] + total: number +} + +export interface StepStats { + step: string + calls: number + cacheHits: number + cacheMisses: number + inputTokens: number + outputTokens: number + avgDurationMs: number + errorCount: number +} + +export interface PipelineStatsResponse { + steps: StepStats[] + totals: { + calls: number + cacheHits: number + cacheMisses: number + inputTokens: number + outputTokens: number + errorCount: number + } + pipelineRun: { + status: string + startedAt?: number + completedAt?: number + wallClockMs?: number + } | null +} + +export interface ActiveConfigResponse { + merged: Record + hasBookOverride: boolean +} + +export interface VersionEntry { + version: number + data?: unknown +} + +export interface VersionListResponse { + versions: VersionEntry[] +} + +export interface LlmLogsParams { + step?: string + itemId?: string + limit?: number + offset?: number +} + export const api = { getBooks: () => request("/books"), @@ -172,4 +254,34 @@ export const api = { signal: AbortSignal.timeout(120_000), } ), + + // --- Debug endpoints --- + + getLlmLogs: (label: string, params?: LlmLogsParams) => { + const qs = new URLSearchParams() + if (params?.step) qs.set("step", params.step) + if (params?.itemId) qs.set("itemId", params.itemId) + if (params?.limit != null) qs.set("limit", String(params.limit)) + if (params?.offset != null) qs.set("offset", String(params.offset)) + const query = qs.toString() + return request( + `/books/${label}/debug/llm-logs${query ? `?${query}` : ""}` + ) + }, + + getPipelineStats: (label: string) => + request(`/books/${label}/debug/stats`), + + getActiveConfig: (label: string) => + request(`/books/${label}/debug/config`), + + getVersionHistory: ( + label: string, + node: string, + itemId: string, + includeData?: boolean + ) => + request( + `/books/${label}/debug/versions/${node}/${itemId}${includeData ? "?includeData=true" : ""}` + ), } diff --git a/apps/studio/src/components/books/BookCard.tsx b/apps/studio/src/components/books/BookCard.tsx deleted file mode 100644 index 22c9e328..00000000 --- a/apps/studio/src/components/books/BookCard.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Link } from "@tanstack/react-router" -import { BookOpen, Trash2 } from "lucide-react" -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import type { BookSummary } from "@/api/client" - -interface BookCardProps { - book: BookSummary - onDelete: (label: string) => void -} - -export function BookCard({ book, onDelete }: BookCardProps) { - return ( - - - -
-
- - {book.title ?? book.label} -
- {book.needsRebuild ? ( - Needs rebuild - ) : ( - 0 ? "default" : "secondary"}> - {book.pageCount > 0 ? `${book.pageCount} pages` : "New"} - - )} -
- {book.title && ( - {book.label} - )} -
- - {book.needsRebuild && ( -

- {book.rebuildReason ?? "Book data is outdated and must be rebuilt."} -

- )} - {book.authors.length > 0 && ( -

- {book.authors.join(", ")} -

- )} -
- - -
- ) -} diff --git a/apps/studio/src/components/books/BookList.tsx b/apps/studio/src/components/books/BookList.tsx deleted file mode 100644 index de20907e..00000000 --- a/apps/studio/src/components/books/BookList.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { BookOpen, Plus } from "lucide-react" -import { Link } from "@tanstack/react-router" -import { Button } from "@/components/ui/button" -import { BookCard } from "./BookCard" -import type { BookSummary } from "@/api/client" - -interface BookListProps { - books: BookSummary[] - onDelete: (label: string) => void -} - -export function BookList({ books, onDelete }: BookListProps) { - if (books.length === 0) { - return - } - - return ( -
- {books.map((book) => ( - - ))} -
- ) -} - -function EmptyState() { - return ( -
- -

No books yet

-

- Add your first book to get started. -

- - - -
- ) -} diff --git a/apps/studio/src/components/debug/ConfigTab.tsx b/apps/studio/src/components/debug/ConfigTab.tsx new file mode 100644 index 00000000..9a01459b --- /dev/null +++ b/apps/studio/src/components/debug/ConfigTab.tsx @@ -0,0 +1,81 @@ +import { useActiveConfig } from "@/hooks/use-debug" +import { Badge } from "@/components/ui/badge" + +interface ConfigTabProps { + label: string +} + +function ConfigSection({ title, data }: { title: string; data: unknown }) { + if (data == null) return null + + return ( +
+
+

{title}

+
+
+        {JSON.stringify(data, null, 2)}
+      
+
+ ) +} + +export function ConfigTab({ label }: ConfigTabProps) { + const { data, isLoading, error } = useActiveConfig(label) + + if (isLoading) { + return
Loading config...
+ } + + if (error) { + return ( +
+ Failed to load config: {error.message} +
+ ) + } + + if (!data) return null + + const { merged, hasBookOverride } = data + const config = merged as Record + + const sections = [ + { key: "text_types", title: "Text Types" }, + { key: "group_types", title: "Group Types" }, + { key: "section_types", title: "Section Types" }, + { key: "metadata_extraction", title: "Metadata Extraction" }, + { key: "text_classification", title: "Text Classification" }, + { key: "image_classification", title: "Image Classification" }, + { key: "page_sectioning", title: "Page Sectioning" }, + { key: "web_rendering", title: "Web Rendering" }, + ] + + const knownKeys = new Set(sections.map((s) => s.key)) + const otherKeys = Object.keys(config).filter((k) => !knownKeys.has(k)) + + return ( +
+
+ Active Configuration + {hasBookOverride ? ( + Book Override + ) : ( + Global Only + )} +
+ +
+ {sections.map(({ key, title }) => + config[key] != null ? ( + + ) : null + )} + + {otherKeys.map((key) => ( + + ))} +
+
+ ) +} diff --git a/apps/studio/src/components/debug/DebugPanel.tsx b/apps/studio/src/components/debug/DebugPanel.tsx new file mode 100644 index 00000000..7fae6539 --- /dev/null +++ b/apps/studio/src/components/debug/DebugPanel.tsx @@ -0,0 +1,131 @@ +import { useState, useCallback, useRef, useEffect } from "react" +import { X, GripHorizontal } from "lucide-react" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { Button } from "@/components/ui/button" +import { StatsTab } from "./StatsTab" +import { LlmLogsTab } from "./LlmLogsTab" +import { ConfigTab } from "./ConfigTab" +import { VersionsTab } from "./VersionsTab" +import type { PipelineProgress } from "@/hooks/use-pipeline" + +const MIN_HEIGHT = 200 +const MAX_HEIGHT_VH = 0.8 +const DEFAULT_HEIGHT_VH = 0.4 + +interface DebugPanelProps { + label: string + progress: PipelineProgress + onClose: () => void +} + +export function DebugPanel({ label, progress, onClose }: DebugPanelProps) { + const [height, setHeight] = useState( + () => Math.floor(window.innerHeight * DEFAULT_HEIGHT_VH) + ) + const dragging = useRef(false) + const panelRef = useRef(null) + + const onDragStart = useCallback((e: React.MouseEvent) => { + e.preventDefault() + dragging.current = true + + const startY = e.clientY + const startHeight = panelRef.current?.offsetHeight ?? height + + const onMove = (ev: MouseEvent) => { + if (!dragging.current) return + const delta = startY - ev.clientY + const maxH = Math.floor(window.innerHeight * MAX_HEIGHT_VH) + const newHeight = Math.min(maxH, Math.max(MIN_HEIGHT, startHeight + delta)) + setHeight(newHeight) + } + + const onUp = () => { + dragging.current = false + document.removeEventListener("mousemove", onMove) + document.removeEventListener("mouseup", onUp) + } + + document.addEventListener("mousemove", onMove) + document.addEventListener("mouseup", onUp) + }, [height]) + + // Clean up on unmount + useEffect(() => { + return () => { + dragging.current = false + } + }, []) + + return ( +
+ {/* Drag handle */} +
+ +
+ + + {/* Header bar */} +
+ + + Stats + + + Logs + {progress.isRunning && progress.liveLlmLogs.length > 0 && ( + + {progress.liveLlmLogs.length > 99 ? "99+" : progress.liveLlmLogs.length} + + )} + + + Config + + + Versions + + + +
+ + + Debug Panel + + + +
+ + {/* Tab contents */} +
+ + + + + + + + + + + + +
+ +
+ ) +} diff --git a/apps/studio/src/components/debug/LlmLogsTab.tsx b/apps/studio/src/components/debug/LlmLogsTab.tsx new file mode 100644 index 00000000..7003422a --- /dev/null +++ b/apps/studio/src/components/debug/LlmLogsTab.tsx @@ -0,0 +1,536 @@ +import { useState, useRef, useEffect } from "react" +import { RefreshCw, AlertTriangle, Check, Loader2, Circle } from "lucide-react" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { useLlmLogs } from "@/hooks/use-debug" +import type { PipelineProgress, LlmLogSummary, StepName } from "@/hooks/use-pipeline" +import type { LlmLogEntry } from "@/api/client" +import { api } from "@/api/client" + +const STEPS = [ + "extract", + "metadata", + "text-classification", + "image-classification", + "page-sectioning", + "web-rendering", +] as const + +const STEP_LABELS: Record = { + extract: "Extract", + metadata: "Metadata", + "text-classification": "Text", + "image-classification": "Images", + "page-sectioning": "Sections", + "web-rendering": "Render", +} + +interface LlmLogsTabProps { + label: string + progress: PipelineProgress +} + +// --- Helpers --- + +function formatSeconds(ms: number): string { + if (ms < 1000) return `${(ms / 1000).toFixed(2)}s` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` + return `${(ms / 60_000).toFixed(1)}m` +} + +function formatRelativeTime(ts: number): string { + const diff = Date.now() - ts + if (diff < 1000) return "just now" + if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago` + if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago` + return `${Math.floor(diff / 3600_000)}h ago` +} + +function formatTimestamp(iso: string): string { + const d = new Date(iso) + const diff = Date.now() - d.getTime() + if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago` + if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago` + return d.toLocaleTimeString() +} + +type RowStatus = "success" | "cached" | "error" + +function getStatusFromLive(entry: LlmLogSummary): RowStatus { + if (entry.validationErrors && entry.validationErrors.length > 0) return "error" + if (entry.cacheHit) return "cached" + return "success" +} + +function getStatusFromHistory(entry: LlmLogEntry): RowStatus { + if (entry.data.validationErrors && entry.data.validationErrors.length > 0) return "error" + if (entry.data.cacheHit) return "cached" + return "success" +} + +const STATUS_DOT: Record = { + success: "bg-green-500", + cached: "bg-yellow-400", + error: "bg-red-500", +} + +const STATUS_LABEL: Record = { + success: "Success", + cached: "Cached", + error: "Error", +} + +// --- Step Tracker --- + +function StepTracker({ progress }: { progress: PipelineProgress }) { + return ( +
+ {STEPS.map((step, i) => { + const completed = progress.completedSteps.has(step) + const running = progress.currentStep === step + const stepProg = progress.stepProgress.get(step) + + return ( +
+ {i > 0 &&
} +
+ {completed && } + {running && } + {!completed && !running && } + {STEP_LABELS[step]} + {running && stepProg?.page != null && stepProg.totalPages != null && ( + + {stepProg.page}/{stepProg.totalPages} + + )} +
+
+ ) + })} +
+ ) +} + +// --- Expanded Detail View --- + +function LogDetail({ data, loading }: { data: LlmLogEntry["data"] | null; loading: boolean }) { + if (loading) { + return ( + + Loading details... + + ) + } + + if (!data) { + return ( + + Details not available yet. + + ) + } + + return ( + +
+ {/* Summary grid */} +
+
+
Prompt
+
{data.promptName}
+
+
+
Model
+
{data.modelId}
+
+
+
Duration
+
{formatSeconds(data.durationMs)}
+
+
+
Cache
+
{data.cacheHit ? "Hit" : "Miss"}
+
+ {data.usage && ( + <> +
+
Input Tokens
+
+ {data.usage.inputTokens.toLocaleString()} +
+
+
+
Output Tokens
+
+ {data.usage.outputTokens.toLocaleString()} +
+
+ + )} +
+ + {/* System prompt */} + {data.system && ( +
+
System Prompt
+
+              {data.system}
+            
+
+ )} + + {/* Messages */} + {data.messages.length > 0 && ( +
+
Messages
+
+ {data.messages.map((msg, i) => ( +
+
+ {msg.role} +
+ {msg.content.map((part, j) => ( +
+ {part.type === "text" ? ( +
+                          {part.text}
+                        
+ ) : ( +
+ [Image: {part.width}x{part.height}, {Math.round(part.byteLength / 1024)}KB] +
+ )} +
+ ))} +
+ ))} +
+
+ )} + + {/* Validation errors */} + {data.validationErrors && data.validationErrors.length > 0 && ( +
+
+ + Validation Errors ({data.validationErrors.length}) +
+
+              {data.validationErrors.join("\n")}
+            
+
+ )} +
+ + ) +} + +// --- Live Log Row (from SSE) --- + +function LiveLogRow({ entry, label }: { entry: LlmLogSummary; label: string }) { + const [expanded, setExpanded] = useState(false) + const [detail, setDetail] = useState(null) + const [loading, setLoading] = useState(false) + const status = getStatusFromLive(entry) + + const handleToggle = async () => { + if (!expanded && !detail) { + setLoading(true) + try { + const result = await api.getLlmLogs(label, { + step: entry.step, + itemId: entry.itemId, + limit: 10, + }) + const match = result.logs.find( + (l) => l.data.promptName === entry.promptName, + ) + if (match) setDetail(match.data) + } catch { + // Detail fetch failed — will show fallback message + } finally { + setLoading(false) + } + } + setExpanded(!expanded) + } + + return ( + <> + + + + + + {formatRelativeTime(entry.receivedAt)} + + + + {entry.step} + + + {entry.itemId} + {entry.promptName} + {entry.modelId} + + {formatSeconds(entry.durationMs)} + + + {entry.inputTokens != null + ? ((entry.inputTokens ?? 0) + (entry.outputTokens ?? 0)).toLocaleString() + : "\u2014"} + + + {expanded && ( + + + + )} + + ) +} + +// --- History Log Row (from REST) --- + +function HistoryLogRow({ entry }: { entry: LlmLogEntry }) { + const [expanded, setExpanded] = useState(false) + const status = getStatusFromHistory(entry) + + return ( + <> + setExpanded(!expanded)} + > + + + + + {formatTimestamp(entry.timestamp)} + + + + {entry.step} + + + {entry.itemId} + {entry.data.promptName} + {entry.data.modelId} + + {formatSeconds(entry.data.durationMs)} + + + {entry.data.usage + ? (entry.data.usage.inputTokens + entry.data.usage.outputTokens).toLocaleString() + : "\u2014"} + + + {expanded && ( + + + + )} + + ) +} + +// --- Main Component --- + +export function LlmLogsTab({ label, progress }: LlmLogsTabProps) { + const [stepFilter, setStepFilter] = useState("") + const [itemIdFilter, setItemIdFilter] = useState("") + const [offset, setOffset] = useState(0) + const limit = 50 + + const scrollRef = useRef(null) + + useEffect(() => { + if (progress.isRunning && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [progress.isRunning, progress.liveLlmLogs.length]) + + const { data, isLoading, refetch } = useLlmLogs(label, { + step: stepFilter || undefined, + itemId: itemIdFilter || undefined, + limit, + offset, + }) + + const filteredLive = progress.liveLlmLogs.filter((log) => { + if (stepFilter && log.step !== stepFilter) return false + if (itemIdFilter && !log.itemId.includes(itemIdFilter)) return false + return true + }) + + const hasLiveLogs = filteredLive.length > 0 + + return ( +
+ {/* Step tracker — visible when pipeline is running or just completed */} + {(progress.isRunning || progress.isComplete) && ( + + )} + + {/* Filter bar */} +
+ {progress.isRunning && ( + + + Live + + )} + + + + { setItemIdFilter(e.target.value); setOffset(0) }} + /> + + + +
+ + {/* Status legend */} +
+ + Success + + + Cached + + + Error + +
+
+ + {/* Log table */} +
+ + + + + + + + + + + + + + {/* Live SSE logs */} + {hasLiveLogs && + filteredLive.map((entry, i) => ( + + )) + } + + {/* Waiting state */} + {progress.isRunning && !hasLiveLogs && ( + + + + )} + + {/* Loading state */} + {isLoading && !hasLiveLogs && ( + + + + )} + + {/* Empty state */} + {data && data.logs.length === 0 && !hasLiveLogs && !progress.isRunning && ( + + + + )} + + {/* History logs from REST API */} + {data?.logs.map((entry) => ( + + ))} + +
+ TimeStepItemPromptModelDurationTokens
+ Waiting for LLM calls... +
+ Loading logs... +
+ No log entries found. Run the pipeline to see LLM call logs. +
+ + {/* Pagination */} + {data && data.total > limit && ( +
+ + {offset + 1}–{Math.min(offset + limit, data.total)} of {data.total} + +
+ + +
+
+ )} +
+
+ ) +} diff --git a/apps/studio/src/components/debug/StatsTab.tsx b/apps/studio/src/components/debug/StatsTab.tsx new file mode 100644 index 00000000..45e9abf2 --- /dev/null +++ b/apps/studio/src/components/debug/StatsTab.tsx @@ -0,0 +1,159 @@ +import { usePipelineStats } from "@/hooks/use-debug" +import { Badge } from "@/components/ui/badge" + +interface StatsTabProps { + label: string + isRunning: boolean +} + +function formatDuration(ms: number): string { + if (ms < 1000) return `${(ms / 1000).toFixed(2)}s` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` + return `${(ms / 60_000).toFixed(1)}m` +} + +function formatTokens(n: number): string { + if (n < 1000) return String(n) + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k` + return `${(n / 1_000_000).toFixed(2)}M` +} + +function estimateCost(inputTokens: number, outputTokens: number): string { + // gpt-4o pricing + const cost = (inputTokens * 2.5 + outputTokens * 10) / 1_000_000 + return `$${cost.toFixed(4)}` +} + +export function StatsTab({ label, isRunning }: StatsTabProps) { + const { data, isLoading, error } = usePipelineStats(label, { + refetchInterval: isRunning ? 3000 : false, + }) + + if (isLoading) { + return
Loading stats...
+ } + + if (error) { + return ( +
+ Failed to load stats: {error.message} +
+ ) + } + + if (!data) return null + + const { steps, totals, pipelineRun } = data + const cacheHitRate = totals.calls > 0 + ? ((totals.cacheHits / totals.calls) * 100).toFixed(1) + : "0" + + return ( +
+ {/* Summary cards row — full width grid */} +
+
+
LLM Calls
+
{totals.calls}
+
+
+
Cache Hit Rate
+
{cacheHitRate}%
+
+
+
Total Tokens
+
+ {formatTokens(totals.inputTokens + totals.outputTokens)} +
+
+ {pipelineRun?.wallClockMs != null ? ( +
+
Wall Clock
+
+ {formatDuration(pipelineRun.wallClockMs)} +
+
+ ) : ( +
+
Wall Clock
+
+
+ )} +
+
Est. Cost
+
+ {estimateCost(totals.inputTokens, totals.outputTokens)} +
+
+
0 ? "bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900" : "bg-card"}`}> +
Errors
+
0 ? "text-destructive" : ""}`}> + {totals.errorCount} +
+
+
+ + {/* Per-step table — full width */} + {steps.length > 0 && ( +
+

Per-Step Breakdown

+
+ + + + + + + + + + + + + + + {steps.map((s) => ( + 0 ? "bg-red-50 dark:bg-red-950/20" : ""}`} + > + + + + + + + + + + ))} + +
StepCallsCache HitsMissesTokens InTokens OutAvg DurationErrors
+ + {s.step} + + {s.calls}{s.cacheHits}{s.cacheMisses} + {formatTokens(s.inputTokens)} + + {formatTokens(s.outputTokens)} + + {formatDuration(s.avgDurationMs)} + + {s.errorCount > 0 ? ( + {s.errorCount} + ) : ( + "0" + )} +
+
+
+ )} + + {steps.length === 0 && ( +
+ No pipeline data yet. Run the pipeline to see stats. +
+ )} +
+ ) +} diff --git a/apps/studio/src/components/debug/VersionsTab.tsx b/apps/studio/src/components/debug/VersionsTab.tsx new file mode 100644 index 00000000..c1e01ae3 --- /dev/null +++ b/apps/studio/src/components/debug/VersionsTab.tsx @@ -0,0 +1,119 @@ +import { useState } from "react" +import { ChevronDown, ChevronRight } from "lucide-react" +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { useVersionHistory } from "@/hooks/use-debug" + +const NODE_TYPES = [ + "text-classification", + "image-classification", + "page-sectioning", + "web-rendering", + "metadata", +] as const + +interface VersionsTabProps { + label: string +} + +function VersionRow({ version, data }: { version: number; data?: unknown }) { + const [expanded, setExpanded] = useState(false) + + return ( +
+ + {expanded && data != null && ( +
+
+            {JSON.stringify(data, null, 2)}
+          
+
+ )} + {expanded && data == null && ( +
+ No data available for this version. +
+ )} +
+ ) +} + +export function VersionsTab({ label }: VersionsTabProps) { + const [node, setNode] = useState("") + const [itemId, setItemId] = useState("") + + const { data, isLoading, error } = useVersionHistory( + label, + node, + itemId, + true + ) + + return ( +
+ {/* Selector bar */} +
+ + + setItemId(e.target.value)} + /> +
+ + {/* Version list */} +
+ {!node || !itemId ? ( +
+ Select a node type and enter an item ID to view version history. +
+ ) : isLoading ? ( +
Loading versions...
+ ) : error ? ( +
+ Failed to load versions: {error.message} +
+ ) : data && data.versions.length === 0 ? ( +
+ No versions found for {node} / {itemId}. +
+ ) : ( + data?.versions.map((v) => ( + + )) + )} +
+
+ ) +} diff --git a/apps/studio/src/components/pipeline/PagePreviewGrid.tsx b/apps/studio/src/components/pipeline/PagePreviewGrid.tsx index 5e124c5e..1f1ec2b7 100644 --- a/apps/studio/src/components/pipeline/PagePreviewGrid.tsx +++ b/apps/studio/src/components/pipeline/PagePreviewGrid.tsx @@ -80,7 +80,7 @@ export function PagePreviewGrid({ label, isRunning }: PagePreviewGridProps) { )}
-
+
{pages.map((page) => ( ))} diff --git a/apps/studio/src/components/pipeline/StepIndicator.tsx b/apps/studio/src/components/pipeline/StepIndicator.tsx index 3f436d10..4fe1ce58 100644 --- a/apps/studio/src/components/pipeline/StepIndicator.tsx +++ b/apps/studio/src/components/pipeline/StepIndicator.tsx @@ -115,9 +115,12 @@ export function StepIndicator({ {/* Status text */}
{state === "active" && progress?.totalPages && ( - - {progress.page ?? 0} / {progress.totalPages} pages - +
+ + {progress.page ?? 0} / {progress.totalPages} pages + + {pct}% +
)} {state === "active" && !progress?.totalPages && ( Processing... @@ -126,33 +129,10 @@ export function StepIndicator({ {state === "error" && Failed} {state === "pending" && Waiting}
- {state === "active" && progress?.totalPages && ( -
-
- - {progress.page ?? 0} / {progress.totalPages} - - - {Math.round( - ((progress.page ?? 0) / progress.totalPages) * 100 - )} - % - -
-
-
-
- {progress.page != null && progress.totalPages && progress.page < progress.totalPages && ( -

- Remaining pages may need multiple LLM attempts... -

- )} -
+ {state === "active" && progress?.totalPages && progress.page != null && progress.page < progress.totalPages && ( +

+ Remaining pages may need multiple LLM attempts... +

)}
) diff --git a/apps/studio/src/hooks/use-debug.ts b/apps/studio/src/hooks/use-debug.ts new file mode 100644 index 00000000..4aaaca8a --- /dev/null +++ b/apps/studio/src/hooks/use-debug.ts @@ -0,0 +1,43 @@ +import { useQuery } from "@tanstack/react-query" +import { api, type LlmLogsParams } from "@/api/client" + +export function useLlmLogs(label: string, params?: LlmLogsParams) { + return useQuery({ + queryKey: ["debug", "llm-logs", label, params], + queryFn: () => api.getLlmLogs(label, params), + enabled: !!label, + }) +} + +export function usePipelineStats( + label: string, + options?: { refetchInterval?: number | false } +) { + return useQuery({ + queryKey: ["debug", "stats", label], + queryFn: () => api.getPipelineStats(label), + enabled: !!label, + refetchInterval: options?.refetchInterval ?? false, + }) +} + +export function useActiveConfig(label: string) { + return useQuery({ + queryKey: ["debug", "config", label], + queryFn: () => api.getActiveConfig(label), + enabled: !!label, + }) +} + +export function useVersionHistory( + label: string, + node: string, + itemId: string, + includeData?: boolean +) { + return useQuery({ + queryKey: ["debug", "versions", label, node, itemId, includeData], + queryFn: () => api.getVersionHistory(label, node, itemId, includeData), + enabled: !!label && !!node && !!itemId, + }) +} diff --git a/apps/studio/src/hooks/use-pipeline.ts b/apps/studio/src/hooks/use-pipeline.ts index 532bda07..06d55b44 100644 --- a/apps/studio/src/hooks/use-pipeline.ts +++ b/apps/studio/src/hooks/use-pipeline.ts @@ -17,6 +17,19 @@ export interface StepProgress { message?: string } +export interface LlmLogSummary { + step: StepName + itemId: string + promptName: string + modelId: string + cacheHit: boolean + durationMs: number + inputTokens?: number + outputTokens?: number + validationErrors?: string[] + receivedAt: number +} + export interface PipelineProgress { isRunning: boolean isComplete: boolean @@ -24,8 +37,11 @@ export interface PipelineProgress { currentStep: StepName | null completedSteps: Set stepProgress: Map + liveLlmLogs: LlmLogSummary[] } +const MAX_LIVE_LOGS = 500 + const INITIAL_PROGRESS: PipelineProgress = { isRunning: false, isComplete: false, @@ -33,6 +49,7 @@ const INITIAL_PROGRESS: PipelineProgress = { currentStep: null, completedSteps: new Set(), stepProgress: new Map(), + liveLlmLogs: [], } /** @@ -86,6 +103,21 @@ export function usePipelineSSE(label: string, enabled: boolean) { stepProgress.delete(data.step) } else if (data.type === "step-error") { next.error = `${data.step}: ${data.error}` + } else if (data.type === "llm-log") { + const entry: LlmLogSummary = { + step: data.step, + itemId: data.itemId, + promptName: data.promptName, + modelId: data.modelId, + cacheHit: data.cacheHit, + durationMs: data.durationMs, + inputTokens: data.inputTokens, + outputTokens: data.outputTokens, + validationErrors: data.validationErrors, + receivedAt: Date.now(), + } + const logs = [...prev.liveLlmLogs, entry] + next.liveLlmLogs = logs.length > MAX_LIVE_LOGS ? logs.slice(-MAX_LIVE_LOGS) : logs } next.stepProgress = stepProgress @@ -103,6 +135,7 @@ export function usePipelineSSE(label: string, enabled: boolean) { })) queryClient.invalidateQueries({ queryKey: ["books", label] }) queryClient.invalidateQueries({ queryKey: ["books"] }) + queryClient.invalidateQueries({ queryKey: ["debug"] }) es.close() }) diff --git a/apps/studio/src/routes/__root.tsx b/apps/studio/src/routes/__root.tsx index ae068576..d41c6ca4 100644 --- a/apps/studio/src/routes/__root.tsx +++ b/apps/studio/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { createRootRoute, Link, Outlet } from "@tanstack/react-router" +import { createRootRoute, Outlet } from "@tanstack/react-router" export const Route = createRootRoute({ component: RootLayout, @@ -6,15 +6,8 @@ export const Route = createRootRoute({ function RootLayout() { return ( -
-
-
- - ADT Studio - -
-
-
+
+
diff --git a/apps/studio/src/routes/books.$label.index.tsx b/apps/studio/src/routes/books.$label.index.tsx index 9357d7be..49e3f1e9 100644 --- a/apps/studio/src/routes/books.$label.index.tsx +++ b/apps/studio/src/routes/books.$label.index.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react" -import { createFileRoute, useNavigate, Link } from "@tanstack/react-router" -import { ArrowLeft, BookOpen, Key, LayoutGrid, Play, Settings2 } from "lucide-react" +import { createFileRoute, Link } from "@tanstack/react-router" +import { BookOpen, LayoutGrid, Play, Settings2 } from "lucide-react" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { Input } from "@/components/ui/input" @@ -24,7 +24,6 @@ export const Route = createFileRoute("/books/$label/")({ function BookDetailPage() { const { label } = Route.useParams() - const navigate = useNavigate() const { data: book, isLoading, error } = useBook(label) const { apiKey, setApiKey, hasApiKey } = useApiKey() @@ -66,12 +65,12 @@ function BookDetailPage() { } if (isLoading) { - return
Loading book...
+ return
Loading book...
} if (error) { return ( -
+
Failed to load book: {error.message}
) @@ -82,24 +81,17 @@ function BookDetailPage() { const showPipelineRunning = progress.isRunning || progress.isComplete || progress.error return ( -
- - -
-
-

+
+ {/* Header row */} +
+
+ + ADT Studio + + / +

{book.title ?? book.label}

- {book.title && ( -

{book.label}

- )}
{book.needsRebuild ? ( Needs rebuild @@ -108,186 +100,194 @@ function BookDetailPage() { {book.pageCount > 0 ? `${book.pageCount} pages` : "New"} )} + {book.pageCount > 0 && ( + + + + )}
-
- {book.needsRebuild && ( - - - Rebuild Required - - {book.rebuildReason ?? - "This book was created with an older storage schema and must be rebuilt."} - - - - )} + {/* Rebuild warning */} + {book.needsRebuild && ( + + + Rebuild Required + + {book.rebuildReason ?? + "This book was created with an older storage schema and must be rebuilt."} + + + + )} - {book.metadata && ( - - - - - Metadata - - - -
+ {/* Two-column layout */} +
+ {/* Left: Book Details */} + + + + + Book Details + + + + {book.metadata ? ( +
{book.metadata.title && ( - <> -
Title
+
+
Title
{book.metadata.title}
- +
)} {book.metadata.authors.length > 0 && ( - <> -
Authors
+
+
Authors
{book.metadata.authors.join(", ")}
- +
)} {book.metadata.publisher && ( - <> -
Publisher
+
+
Publisher
{book.metadata.publisher}
- +
)} {book.metadata.language_code && ( - <> -
Language
+
+
Language
{book.metadata.language_code}
- +
)} +
+
Pages
+
{book.pageCount > 0 ? `${book.pageCount} extracted` : "None yet"}
+
-
-
- )} + ) : ( +

+ No metadata extracted yet. Run the pipeline to extract book details. +

+ )} + + - {/* Pipeline: config form (pre-run) or progress (running/done) */} + {/* Right: Pipeline Config or Pipeline Progress */} {showPipelineRunning ? ( - +
+ +
) : ( - - - - + + + + Pipeline Configuration Configure options before running the pipeline. - - {/* API Key */} -
- - setApiKey(e.target.value)} - placeholder="sk-..." - className="max-w-md font-mono" - /> -

- Stored in your browser only. Never sent to our servers. -

-
+ +
+ {/* API Key */} +
+ + setApiKey(e.target.value)} + placeholder="sk-..." + className="font-mono" + /> +

+ Stored in your browser only. Never sent to our servers. +

+
- {/* Page Range */} -
- -
-
- setStartPage(e.target.value)} - placeholder="First" - className="w-24" - /> - to + {/* Page Range + Concurrency in a row */} +
+
+ +
+ setStartPage(e.target.value)} + placeholder="First" + className="w-20" + /> + to + setEndPage(e.target.value)} + placeholder="Last" + className="w-20" + /> +
+

+ Leave empty for all pages +

+
+ +
+ setEndPage(e.target.value)} - placeholder="Last" - className="w-24" + max={64} + value={concurrency} + onChange={(e) => setConcurrency(e.target.value)} + className="w-20" /> +

+ Parallel LLM calls +

- - Leave empty to process all pages -
-
- {/* Concurrency */} -
- -
- setConcurrency(e.target.value)} - className="w-24" - /> - - Parallel LLM calls (higher = faster but more API usage) - + {/* Run button */} +
+ + {!hasApiKey && ( + + Enter your API key above to run. + + )}
-
- {/* Run button + error */} -
- - {!hasApiKey && ( - - Enter your API key above to run. - + {runPipeline.isError && ( +

+ Failed to start pipeline: {runPipeline.error.message} +

)}
- - {runPipeline.isError && ( -

- Failed to start pipeline: {runPipeline.error.message} -

- )} )} - - {(progress.isRunning || progress.isComplete || book.pageCount > 0) && ( - - )} - - {book.pageCount > 0 && ( - - - - )}
+ + {/* Full-width page preview grid */} + {(progress.isRunning || progress.isComplete || book.pageCount > 0) && ( + + )}
) } diff --git a/apps/studio/src/routes/books.$label.pages.$pageId.tsx b/apps/studio/src/routes/books.$label.pages.$pageId.tsx index 5b07e832..b4dbf62f 100644 --- a/apps/studio/src/routes/books.$label.pages.$pageId.tsx +++ b/apps/studio/src/routes/books.$label.pages.$pageId.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { createFileRoute, useNavigate, Link } from "@tanstack/react-router" import { useState, useCallback, useEffect, useRef } from "react" import { ArrowLeft, ArrowRight, FileText, Image, Layers, Loader2, AlertCircle, ImageOff } from "lucide-react" import { Button } from "@/components/ui/button" @@ -147,12 +147,12 @@ function PageDetailPage() { false if (isLoading) { - return
Loading page...
+ return
Loading page...
} if (error) { return ( -
+
Failed to load page: {error.message}
) @@ -174,24 +174,19 @@ function PageDetailPage() { .join("\n") return ( -
- {/* Compact header: nav + title + toolbar in one row */} -
-
- -

Page {page.pageNumber}

+
+ {/* Compact header: breadcrumb + nav + toolbar in one row */} +
+
+ + ADT Studio + + / + + {label} + + / + Page {page.pageNumber}
-
- -
-
-

- {book?.title ?? label} — Storyboard -

-

+

+
+
+ + ADT Studio + + / + + {book?.title ?? label} + + / +

Storyboard

+ {pages?.length ?? 0} pages - {pages && - ` (${pages.filter((p) => p.hasRendering).length} rendered)`} -

+ {pages && ` (${pages.filter((p) => p.hasRendering).length} rendered)`} +
+ )} +
+ ) } diff --git a/apps/studio/src/routes/books.new.tsx b/apps/studio/src/routes/books.new.tsx index be483902..7d9f50b9 100644 --- a/apps/studio/src/routes/books.new.tsx +++ b/apps/studio/src/routes/books.new.tsx @@ -1,6 +1,6 @@ import { useState, useCallback } from "react" -import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { Upload, ArrowLeft, FileText } from "lucide-react" +import { createFileRoute, useNavigate, Link } from "@tanstack/react-router" +import { Upload, FileText } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { @@ -62,17 +62,14 @@ function AddBookPage() { const isValid = !!file && !!label && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(label) return ( -
- - -

Add Book

+
+
+ + ADT Studio + + / +

Add Book

+
diff --git a/apps/studio/src/routes/index.tsx b/apps/studio/src/routes/index.tsx index 1f1d8efe..cfc41bf2 100644 --- a/apps/studio/src/routes/index.tsx +++ b/apps/studio/src/routes/index.tsx @@ -1,48 +1,308 @@ import { useState } from "react" -import { createFileRoute } from "@tanstack/react-router" -import { Link } from "@tanstack/react-router" -import { Plus } from "lucide-react" +import { createFileRoute, Link } from "@tanstack/react-router" +import { + Plus, + Upload, + Cpu, + LayoutGrid, + Pencil, + Package, + ArrowRight, + BookOpen, + Trash2, + FileText, + Building2, + User, + Globe, +} from "lucide-react" import { Button } from "@/components/ui/button" -import { BookList } from "@/components/books/BookList" +import { Badge } from "@/components/ui/badge" import { DeleteBookDialog } from "@/components/books/DeleteBookDialog" import { useBooks, useDeleteBook } from "@/hooks/use-books" +import type { BookSummary } from "@/api/client" export const Route = createFileRoute("/")({ component: HomePage, }) +const WORKFLOW = [ + { + icon: Upload, + title: "Upload PDF", + desc: "Add your book as a PDF. Each page is extracted as an image ready for AI processing.", + }, + { + icon: Cpu, + title: "Run Pipeline", + desc: "AI extracts text, classifies content, identifies images, and generates HTML pages.", + }, + { + icon: LayoutGrid, + title: "Review Storyboard", + desc: "Browse all generated pages side-by-side in a visual grid with the originals.", + }, + { + icon: Pencil, + title: "Edit & Refine", + desc: "Fine-tune text, prune images, and re-render pages until the output is perfect.", + }, + { + icon: Package, + title: "Export", + desc: "Download your finished book as ADT, ePub, or WebPub — ready to distribute.", + }, +] + +function DetailRow({ + icon: Icon, + label, + value, +}: { + icon: React.ComponentType<{ className?: string }> + label: string + value: string +}) { + return ( +
+ + {label} + {value} +
+ ) +} + +function BookRow({ + book, + onDelete, +}: { + book: BookSummary + onDelete: (label: string) => void +}) { + const hasMetadata = book.title || book.authors.length > 0 + return ( +
+
+ {/* Main content — clickable */} + + {/* Top: title + badges */} +
+
+ +

+ {book.title ?? book.label} +

+
+
+ {book.needsRebuild && ( + + Needs rebuild + + )} + {book.languageCode && ( + + {book.languageCode.toUpperCase()} + + )} + {!book.needsRebuild && ( + 0 ? "default" : "secondary"} + className="text-[11px] px-2 py-0.5" + > + {book.pageCount > 0 ? `${book.pageCount} pages` : "New"} + + )} + {!book.hasSourcePdf && ( + + No PDF + + )} +
+
+ + {/* Rebuild warning */} + {book.needsRebuild && ( +

+ {book.rebuildReason ?? "Book data is outdated and must be rebuilt."} +

+ )} + + {/* Details */} + {hasMetadata ? ( +
+ {book.title && ( + + )} + {book.authors.length > 0 && ( + 1 ? "Authors" : "Author"} + value={book.authors.join(", ")} + /> + )} + {book.publisher && ( + + )} + {book.languageCode && ( + + )} +
+ ) : ( +

+ No metadata yet — run the pipeline to extract book details +

+ )} + + + {/* Actions — always visible */} +
+ + +
+
+
+ ) +} + function HomePage() { const { data: books, isLoading, error } = useBooks() const deleteMutation = useDeleteBook() const [deleteLabel, setDeleteLabel] = useState(null) if (isLoading) { - return
Loading books...
+ return ( +
+ Loading books... +
+ ) } if (error) { return ( -
+
Failed to load books: {error.message}
) } + const bookList = books ?? [] + return ( -
-
-

Books

- {books && books.length > 0 && ( - - +
+ {/* Left — workflow guide (30%) */} +
+
+

ADT Studio

+

+ Accessible Digital Textbooks +

+
+ +
+

+ How it works +

+
+ {WORKFLOW.map((step, i) => ( +
+
+
+ {i + 1} +
+ {i < WORKFLOW.length - 1 && ( +
+ )} +
+
+
+ + {step.title} +
+

+ {step.desc} +

+
+
+ ))} +
+ + + Get started - )} +
- + {/* Right — books list (70%) */} +
+
+

+ Your Books{bookList.length > 0 && ` (${bookList.length})`} +

+ +
+
+ {bookList.map((book) => ( + + ))} + {bookList.length === 0 && ( + +
+
+ +
+ + Add your first book + + + Upload a PDF to get started + +
+ + )} +
+