Преглед на файлове

fix(studio): harden api and activity hooks

Ma преди 7 месеца
родител
ревизия
b51a214cd6

+ 40 - 0
packages/studio/src/hooks/use-api.test.ts

@@ -0,0 +1,40 @@
+import { describe, expect, it, vi } from "vitest";
+import { buildApiUrl, fetchJson } from "./use-api";
+
+describe("buildApiUrl", () => {
+  it("returns null for blank paths so callers can skip requests", () => {
+    expect(buildApiUrl("")).toBeNull();
+    expect(buildApiUrl("   ")).toBeNull();
+  });
+
+  it("prefixes api paths once", () => {
+    expect(buildApiUrl("/books")).toBe("/api/books");
+    expect(buildApiUrl("books")).toBe("/api/books");
+    expect(buildApiUrl("/api/books")).toBe("/api/books");
+  });
+});
+
+describe("fetchJson", () => {
+  it("surfaces API error payloads on non-ok responses", async () => {
+    const fetchImpl = vi.fn(async () =>
+      new Response(JSON.stringify({ error: "Bad request" }), {
+        status: 400,
+        headers: { "Content-Type": "application/json" },
+      }),
+    );
+
+    await expect(fetchJson("/books", {}, { fetchImpl })).rejects.toThrow("Bad request");
+  });
+
+  it("falls back to status text when the body is not JSON", async () => {
+    const fetchImpl = vi.fn(async () =>
+      new Response("boom", {
+        status: 500,
+        statusText: "Internal Server Error",
+        headers: { "Content-Type": "text/plain" },
+      }),
+    );
+
+    await expect(fetchJson("/books", {}, { fetchImpl })).rejects.toThrow("500 Internal Server Error");
+  });
+});

+ 67 - 5
packages/studio/src/hooks/use-api.ts

@@ -2,16 +2,81 @@ import { useState, useEffect, useCallback } from "react";
 
 const BASE = "/api";
 
+export function buildApiUrl(path: string): string | null {
+  const normalized = String(path ?? "").trim();
+  if (!normalized) return null;
+  if (normalized.startsWith(`${BASE}/`) || normalized === BASE) {
+    return normalized;
+  }
+  return normalized.startsWith("/") ? `${BASE}${normalized}` : `${BASE}/${normalized}`;
+}
+
+async function readErrorMessage(res: Response): Promise<string> {
+  const contentType = res.headers.get("content-type") ?? "";
+  if (contentType.includes("application/json")) {
+    try {
+      const json = await res.json() as { error?: unknown };
+      if (typeof json.error === "string" && json.error.trim()) {
+        return json.error;
+      }
+    } catch {
+      // fall through
+    }
+  }
+  return `${res.status} ${res.statusText}`.trim();
+}
+
+export async function fetchJson<T>(
+  path: string,
+  init: RequestInit = {},
+  deps?: { readonly fetchImpl?: typeof fetch },
+): Promise<T> {
+  const url = buildApiUrl(path);
+  if (!url) {
+    throw new Error("API path is required");
+  }
+
+  const fetchImpl = deps?.fetchImpl ?? fetch;
+  const res = await fetchImpl(url, init);
+
+  if (!res.ok) {
+    throw new Error(await readErrorMessage(res));
+  }
+
+  if (res.status === 204) {
+    return undefined as T;
+  }
+
+  const contentType = res.headers.get("content-type") ?? "";
+  if (!contentType.includes("application/json")) {
+    const text = await res.text();
+    if (!text.trim()) {
+      return undefined as T;
+    }
+    return JSON.parse(text) as T;
+  }
+
+  return await res.json() as T;
+}
+
 export function useApi<T>(path: string) {
   const [data, setData] = useState<T | null>(null);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string | null>(null);
 
   const refetch = useCallback(async () => {
+    const url = buildApiUrl(path);
+    if (!url) {
+      setData(null);
+      setError(null);
+      setLoading(false);
+      return;
+    }
+
     setLoading(true);
     setError(null);
     try {
-      const res = await fetch(`${BASE}${path}`);
+      const res = await fetch(url);
       if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
       const json = await res.json();
       setData(json as T);
@@ -30,12 +95,9 @@ export function useApi<T>(path: string) {
 }
 
 export async function postApi<T>(path: string, body?: unknown): Promise<T> {
-  const res = await fetch(`${BASE}${path}`, {
+  return fetchJson<T>(path, {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: body ? JSON.stringify(body) : undefined,
   });
-  const json = await res.json();
-  if (!res.ok) throw new Error((json as { error?: string }).error ?? `${res.status}`);
-  return json as T;
 }

+ 81 - 0
packages/studio/src/hooks/use-book-activity.test.ts

@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+import type { SSEMessage } from "./use-sse";
+import { deriveActiveBookIds, deriveBookActivity, shouldRefetchBookView } from "./use-book-activity";
+
+function msg(event: string, data: unknown, timestamp: number): SSEMessage {
+  return { event, data, timestamp };
+}
+
+describe("deriveBookActivity", () => {
+  it("keeps a book in writing state after write:start until completion", () => {
+    const messages: ReadonlyArray<SSEMessage> = [
+      msg("write:start", { bookId: "alpha" }, 1),
+      msg("log", { message: "Phase 1" }, 2),
+      msg("llm:progress", { totalChars: 1200 }, 3),
+    ];
+
+    expect(deriveBookActivity(messages, "alpha")).toMatchObject({
+      writing: true,
+      drafting: false,
+      lastError: null,
+    });
+  });
+
+  it("clears writing state after completion or error", () => {
+    const completed: ReadonlyArray<SSEMessage> = [
+      msg("write:start", { bookId: "alpha" }, 1),
+      msg("write:complete", { bookId: "alpha", chapterNumber: 2 }, 2),
+    ];
+    const errored: ReadonlyArray<SSEMessage> = [
+      msg("write:start", { bookId: "alpha" }, 1),
+      msg("write:error", { bookId: "alpha", error: "locked" }, 2),
+    ];
+
+    expect(deriveBookActivity(completed, "alpha")).toMatchObject({
+      writing: false,
+      lastError: null,
+    });
+    expect(deriveBookActivity(errored, "alpha")).toMatchObject({
+      writing: false,
+      lastError: "locked",
+    });
+  });
+
+  it("tracks drafting independently from writing", () => {
+    const messages: ReadonlyArray<SSEMessage> = [
+      msg("draft:start", { bookId: "alpha" }, 1),
+      msg("write:start", { bookId: "beta" }, 2),
+    ];
+
+    expect(deriveBookActivity(messages, "alpha")).toMatchObject({
+      writing: false,
+      drafting: true,
+    });
+  });
+});
+
+describe("deriveActiveBookIds", () => {
+  it("returns only books with in-flight background work", () => {
+    const messages: ReadonlyArray<SSEMessage> = [
+      msg("write:start", { bookId: "alpha" }, 1),
+      msg("draft:start", { bookId: "beta" }, 2),
+      msg("write:complete", { bookId: "alpha", chapterNumber: 2 }, 3),
+      msg("write:start", { bookId: "gamma" }, 4),
+      msg("draft:error", { bookId: "beta", error: "quota" }, 5),
+    ];
+
+    expect([...deriveActiveBookIds(messages)].sort()).toEqual(["gamma"]);
+  });
+});
+
+describe("shouldRefetchBookView", () => {
+  it("refreshes the book detail view after terminal background jobs for that book", () => {
+    expect(shouldRefetchBookView(msg("write:complete", { bookId: "alpha" }, 1), "alpha")).toBe(true);
+    expect(shouldRefetchBookView(msg("draft:error", { bookId: "alpha", error: "quota" }, 1), "alpha")).toBe(true);
+    expect(shouldRefetchBookView(msg("rewrite:complete", { bookId: "alpha", chapterNumber: 3 }, 1), "alpha")).toBe(true);
+    expect(shouldRefetchBookView(msg("revise:error", { bookId: "alpha", error: "bad" }, 1), "alpha")).toBe(true);
+    expect(shouldRefetchBookView(msg("audit:complete", { bookId: "alpha", chapter: 3, passed: true }, 1), "alpha")).toBe(true);
+    expect(shouldRefetchBookView(msg("audit:start", { bookId: "alpha", chapter: 3 }, 1), "alpha")).toBe(false);
+    expect(shouldRefetchBookView(msg("rewrite:complete", { bookId: "beta" }, 1), "alpha")).toBe(false);
+  });
+});

+ 94 - 0
packages/studio/src/hooks/use-book-activity.ts

@@ -0,0 +1,94 @@
+import type { SSEMessage } from "./use-sse";
+
+const START_EVENTS = new Set(["write:start", "draft:start"]);
+const TERMINAL_EVENTS = new Set(["write:complete", "write:error", "draft:complete", "draft:error"]);
+const BOOK_REFRESH_EVENTS = new Set([
+  "write:complete",
+  "write:error",
+  "draft:complete",
+  "draft:error",
+  "rewrite:complete",
+  "rewrite:error",
+  "revise:complete",
+  "revise:error",
+  "audit:complete",
+  "audit:error",
+]);
+
+export interface BookActivity {
+  readonly writing: boolean;
+  readonly drafting: boolean;
+  readonly lastError: string | null;
+}
+
+function getBookId(message: SSEMessage): string | null {
+  const data = message.data as { bookId?: unknown } | null;
+  return typeof data?.bookId === "string" ? data.bookId : null;
+}
+
+export function deriveActiveBookIds(messages: ReadonlyArray<SSEMessage>): ReadonlySet<string> {
+  const active = new Set<string>();
+
+  for (const message of messages) {
+    const bookId = getBookId(message);
+    if (!bookId) continue;
+
+    if (START_EVENTS.has(message.event)) {
+      active.add(bookId);
+      continue;
+    }
+
+    if (TERMINAL_EVENTS.has(message.event)) {
+      active.delete(bookId);
+    }
+  }
+
+  return active;
+}
+
+export function deriveBookActivity(messages: ReadonlyArray<SSEMessage>, bookId: string): BookActivity {
+  let writing = false;
+  let drafting = false;
+  let lastError: string | null = null;
+
+  for (const message of messages) {
+    if (getBookId(message) !== bookId) continue;
+
+    const data = message.data as { error?: unknown } | null;
+
+    switch (message.event) {
+      case "write:start":
+        writing = true;
+        lastError = null;
+        break;
+      case "write:complete":
+        writing = false;
+        lastError = null;
+        break;
+      case "write:error":
+        writing = false;
+        lastError = typeof data?.error === "string" ? data.error : "Unknown error";
+        break;
+      case "draft:start":
+        drafting = true;
+        lastError = null;
+        break;
+      case "draft:complete":
+        drafting = false;
+        lastError = null;
+        break;
+      case "draft:error":
+        drafting = false;
+        lastError = typeof data?.error === "string" ? data.error : "Unknown error";
+        break;
+      default:
+        break;
+    }
+  }
+
+  return { writing, drafting, lastError };
+}
+
+export function shouldRefetchBookView(message: SSEMessage, bookId: string): boolean {
+  return getBookId(message) === bookId && BOOK_REFRESH_EVENTS.has(message.event);
+}

+ 51 - 0
packages/studio/src/hooks/use-sse.test.ts

@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import { STUDIO_SSE_EVENTS } from "./use-sse";
+
+describe("STUDIO_SSE_EVENTS", () => {
+  it("covers the server lifecycle events that drive the UI", () => {
+    expect(STUDIO_SSE_EVENTS).toEqual(expect.arrayContaining([
+      "book:creating",
+      "book:created",
+      "book:error",
+      "write:start",
+      "write:complete",
+      "write:error",
+      "draft:start",
+      "draft:complete",
+      "draft:error",
+      "daemon:started",
+      "daemon:stopped",
+      "daemon:error",
+      "audit:start",
+      "audit:complete",
+      "audit:error",
+      "revise:start",
+      "revise:complete",
+      "revise:error",
+      "rewrite:start",
+      "rewrite:complete",
+      "rewrite:error",
+      "agent:start",
+      "agent:complete",
+      "agent:error",
+      "import:start",
+      "import:complete",
+      "import:error",
+      "fanfic:start",
+      "fanfic:complete",
+      "fanfic:error",
+      "fanfic:refresh:start",
+      "fanfic:refresh:complete",
+      "fanfic:refresh:error",
+      "style:start",
+      "style:complete",
+      "style:error",
+      "radar:start",
+      "radar:complete",
+      "radar:error",
+      "log",
+      "llm:progress",
+      "ping",
+    ]));
+  });
+});

+ 47 - 6
packages/studio/src/hooks/use-sse.ts

@@ -6,6 +6,52 @@ export interface SSEMessage {
   readonly timestamp: number;
 }
 
+export const STUDIO_SSE_EVENTS = [
+  "book:creating",
+  "book:created",
+  "book:error",
+  "write:start",
+  "write:complete",
+  "write:error",
+  "draft:start",
+  "draft:complete",
+  "draft:error",
+  "daemon:chapter",
+  "daemon:started",
+  "daemon:stopped",
+  "daemon:error",
+  "agent:start",
+  "agent:complete",
+  "agent:error",
+  "audit:start",
+  "audit:complete",
+  "audit:error",
+  "revise:start",
+  "revise:complete",
+  "revise:error",
+  "rewrite:start",
+  "rewrite:complete",
+  "rewrite:error",
+  "style:start",
+  "style:complete",
+  "style:error",
+  "import:start",
+  "import:complete",
+  "import:error",
+  "fanfic:start",
+  "fanfic:complete",
+  "fanfic:error",
+  "fanfic:refresh:start",
+  "fanfic:refresh:complete",
+  "fanfic:refresh:error",
+  "radar:start",
+  "radar:complete",
+  "radar:error",
+  "log",
+  "llm:progress",
+  "ping",
+] as const;
+
 export function useSSE(url = "/api/events") {
   const [messages, setMessages] = useState<ReadonlyArray<SSEMessage>>([]);
   const [connected, setConnected] = useState(false);
@@ -27,12 +73,7 @@ export function useSSE(url = "/api/events") {
       }
     };
 
-    const events = [
-      "write:start", "write:complete", "write:error",
-      "draft:start", "draft:complete", "draft:error",
-      "log", "llm:progress", "ping",
-    ];
-    for (const event of events) {
+    for (const event of STUDIO_SSE_EVENTS) {
       es.addEventListener(event, handleEvent);
     }