Просмотр исходного кода

feat(studio): add InkOS Studio web workbench

Full-featured web UI for novel writing management:
- Dashboard: book list, write-next trigger, SSE real-time progress
- Book Detail: chapter table, write/draft/approve/reject actions
- Chapter Reader: prose reading with approve/reject controls
- Book Create: form with language/genre selection (dynamic genre list)
- Analytics: stats cards with status distribution
- Config: project info display

Architecture:
- Hono API server (direct core import, no shell exec)
- Async write operations with SSE progress (logger sink → broadcast)
- Single-process production mode (API + static frontend)
- Dark/light theme (system time auto-switch + manual toggle)
- Full i18n (zh/en based on project language setting)
- Inter font, blue accent, sticky nav with backdrop-blur
Ma 7 месяцев назад
Родитель
Сommit
6588609ab6

+ 12 - 0
packages/studio/index.html

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>InkOS Studio</title>
+  </head>
+  <body>
+    <div id="root"></div>
+    <script type="module" src="/src/main.tsx"></script>
+  </body>
+</html>

+ 31 - 0
packages/studio/package.json

@@ -0,0 +1,31 @@
+{
+  "name": "@actalk/inkos-studio",
+  "version": "0.4.9",
+  "private": true,
+  "description": "InkOS Studio — Web workbench for novel writing",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@actalk/inkos-core": "workspace:*",
+    "@hono/node-server": "^1.13.0",
+    "hono": "^4.7.0",
+    "dotenv": "^16.4.0",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0"
+  },
+  "devDependencies": {
+    "@types/react": "^19.0.0",
+    "@types/react-dom": "^19.0.0",
+    "@vitejs/plugin-react": "^4.4.0",
+    "autoprefixer": "^10.4.0",
+    "tailwindcss": "^4.0.0",
+    "@tailwindcss/vite": "^4.0.0",
+    "typescript": "^5.8.0",
+    "vite": "^6.0.0"
+  }
+}

+ 96 - 0
packages/studio/src/App.tsx

@@ -0,0 +1,96 @@
+import { useState } from "react";
+import { Dashboard } from "./pages/Dashboard";
+import { BookDetail } from "./pages/BookDetail";
+import { BookCreate } from "./pages/BookCreate";
+import { ChapterReader } from "./pages/ChapterReader";
+import { Analytics } from "./pages/Analytics";
+import { ConfigView } from "./pages/ConfigView";
+import { useSSE } from "./hooks/use-sse";
+import { useTheme } from "./hooks/use-theme";
+import { useI18n } from "./hooks/use-i18n";
+
+type Route =
+  | { page: "dashboard" }
+  | { page: "book"; bookId: string }
+  | { page: "book-create" }
+  | { page: "chapter"; bookId: string; chapterNumber: number }
+  | { page: "analytics"; bookId: string }
+  | { page: "config" };
+
+export function App() {
+  const [route, setRoute] = useState<Route>({ page: "dashboard" });
+  const sse = useSSE();
+  const { theme, setTheme } = useTheme();
+  const { t } = useI18n();
+
+  const isDark = theme === "dark";
+
+  const nav = {
+    toDashboard: () => setRoute({ page: "dashboard" }),
+    toBook: (bookId: string) => setRoute({ page: "book", bookId }),
+    toBookCreate: () => setRoute({ page: "book-create" }),
+    toChapter: (bookId: string, chapterNumber: number) =>
+      setRoute({ page: "chapter", bookId, chapterNumber }),
+    toAnalytics: (bookId: string) => setRoute({ page: "analytics", bookId }),
+    toConfig: () => setRoute({ page: "config" }),
+  };
+
+  return (
+    <div className={`min-h-screen ${isDark ? "bg-zinc-950 text-zinc-100" : "bg-zinc-50 text-zinc-900"}`}>
+      <header className={`sticky top-0 z-10 backdrop-blur-sm border-b px-6 py-3 flex items-center justify-between ${
+        isDark ? "bg-zinc-950/80 border-zinc-800" : "bg-white/80 border-zinc-200"
+      }`}>
+        <div className="flex items-center gap-6">
+          <button
+            onClick={nav.toDashboard}
+            className="text-lg font-bold tracking-tight hover:opacity-80 transition-opacity"
+          >
+            <span className={isDark ? "text-blue-400" : "text-blue-600"}>Ink</span>OS Studio
+          </button>
+          <nav className={`flex gap-1 text-sm ${isDark ? "text-zinc-400" : "text-zinc-500"}`}>
+            {([
+              { label: t("nav.books"), action: nav.toDashboard, active: route.page === "dashboard" },
+              { label: t("nav.newBook"), action: nav.toBookCreate, active: route.page === "book-create" },
+              { label: t("nav.config"), action: nav.toConfig, active: route.page === "config" },
+            ] as const).map((item) => (
+              <button
+                key={item.label}
+                onClick={item.action}
+                className={`px-3 py-1.5 rounded-md transition-colors ${
+                  item.active
+                    ? isDark ? "bg-zinc-800 text-zinc-100" : "bg-zinc-100 text-zinc-900"
+                    : isDark ? "hover:bg-zinc-800/50 hover:text-zinc-200" : "hover:bg-zinc-100 hover:text-zinc-700"
+                }`}
+              >
+                {item.label}
+              </button>
+            ))}
+          </nav>
+        </div>
+        <div className="flex items-center gap-3">
+          <button
+            onClick={() => setTheme(isDark ? "light" : "dark")}
+            className={`px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${
+              isDark ? "bg-zinc-800 text-zinc-400 hover:text-zinc-200" : "bg-zinc-100 text-zinc-500 hover:text-zinc-700"
+            }`}
+          >
+            {isDark ? "☀️" : "🌙"}
+          </button>
+          <div className={`flex items-center gap-1.5 text-xs ${isDark ? "text-zinc-500" : "text-zinc-400"}`}>
+            <span className={`inline-block w-1.5 h-1.5 rounded-full ${sse.connected ? "bg-emerald-500" : "bg-zinc-400"}`} />
+            {sse.connected ? t("nav.connected") : t("nav.disconnected")}
+          </div>
+        </div>
+      </header>
+
+      <main className="max-w-5xl mx-auto px-6 py-8">
+        {route.page === "dashboard" && <Dashboard nav={nav} sse={sse} theme={theme} t={t} />}
+        {route.page === "book" && <BookDetail bookId={route.bookId} nav={nav} theme={theme} t={t} />}
+        {route.page === "book-create" && <BookCreate nav={nav} theme={theme} t={t} />}
+        {route.page === "chapter" && <ChapterReader bookId={route.bookId} chapterNumber={route.chapterNumber} nav={nav} theme={theme} t={t} />}
+        {route.page === "analytics" && <Analytics bookId={route.bookId} nav={nav} theme={theme} t={t} />}
+        {route.page === "config" && <ConfigView nav={nav} theme={theme} t={t} />}
+      </main>
+    </div>
+  );
+}

+ 30 - 0
packages/studio/src/api/index.ts

@@ -0,0 +1,30 @@
+import { startStudioServer } from "./server.js";
+import { resolve, join, dirname } from "node:path";
+import { existsSync } from "node:fs";
+import { execSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+const root = process.argv[2] ?? process.env.INKOS_PROJECT_ROOT ?? process.cwd();
+const port = parseInt(process.env.INKOS_STUDIO_PORT ?? "4567", 10);
+
+// Find studio package root (2 levels up from src/api/)
+const studioRoot = resolve(__dirname, "../..");
+const distDir = join(studioRoot, "dist");
+
+// Auto-build frontend if dist/ doesn't exist
+if (!existsSync(join(distDir, "index.html"))) {
+  console.log("Building frontend...");
+  try {
+    execSync("npx vite build", { cwd: studioRoot, stdio: "inherit" });
+  } catch {
+    console.error("Failed to build frontend. Run 'cd packages/studio && pnpm build' manually.");
+    process.exit(1);
+  }
+}
+
+startStudioServer(root, port, { staticDir: distDir }).catch((e) => {
+  console.error("Failed to start studio:", e);
+  process.exit(1);
+});

+ 365 - 0
packages/studio/src/api/server.ts

@@ -0,0 +1,365 @@
+import { Hono } from "hono";
+import { cors } from "hono/cors";
+import { streamSSE } from "hono/streaming";
+import { serve } from "@hono/node-server";
+import {
+  StateManager,
+  PipelineRunner,
+  createLLMClient,
+  createLogger,
+  computeAnalytics,
+  type PipelineConfig,
+  type ProjectConfig,
+  type LogSink,
+  type LogEntry,
+} from "@actalk/inkos-core";
+import { readFile, readdir } from "node:fs/promises";
+import { join } from "node:path";
+
+// --- Event bus for SSE ---
+
+type EventHandler = (event: string, data: unknown) => void;
+const subscribers = new Set<EventHandler>();
+
+function broadcast(event: string, data: unknown): void {
+  for (const handler of subscribers) {
+    handler(event, data);
+  }
+}
+
+// --- Server factory ---
+
+export function createStudioServer(config: ProjectConfig, root: string) {
+  const app = new Hono();
+  const state = new StateManager(root);
+
+  app.use("/*", cors());
+
+  // Logger sink that broadcasts to SSE
+  const sseSink: LogSink = {
+    write(entry: LogEntry): void {
+      broadcast("log", { level: entry.level, tag: entry.tag, message: entry.message });
+    },
+  };
+
+  function buildPipelineConfig(): PipelineConfig {
+    const logger = createLogger({ tag: "studio", sinks: [sseSink] });
+    return {
+      client: createLLMClient(config.llm),
+      model: config.llm.model,
+      projectRoot: root,
+      defaultLLMConfig: config.llm,
+      modelOverrides: config.modelOverrides,
+      notifyChannels: config.notify,
+      logger,
+      onStreamProgress: (progress) => {
+        if (progress.status === "streaming") {
+          broadcast("llm:progress", {
+            elapsedMs: progress.elapsedMs,
+            totalChars: progress.totalChars,
+            chineseChars: progress.chineseChars,
+          });
+        }
+      },
+    };
+  }
+
+  // --- Books ---
+
+  app.get("/api/books", async (c) => {
+    const bookIds = await state.listBooks();
+    const books = await Promise.all(
+      bookIds.map(async (id) => {
+        const book = await state.loadBookConfig(id);
+        const nextChapter = await state.getNextChapterNumber(id);
+        return { ...book, chaptersWritten: nextChapter - 1 };
+      }),
+    );
+    return c.json({ books });
+  });
+
+  app.get("/api/books/:id", async (c) => {
+    const id = c.req.param("id");
+    try {
+      const book = await state.loadBookConfig(id);
+      const chapters = await state.loadChapterIndex(id);
+      const nextChapter = await state.getNextChapterNumber(id);
+      return c.json({ book, chapters, nextChapter });
+    } catch {
+      return c.json({ error: `Book "${id}" not found` }, 404);
+    }
+  });
+
+  // --- Genres ---
+
+  app.get("/api/genres", async (c) => {
+    const { listAvailableGenres } = await import("@actalk/inkos-core");
+    const genres = await listAvailableGenres(root);
+    return c.json({ genres });
+  });
+
+  // --- Book Create ---
+
+  app.post("/api/books/create", async (c) => {
+    const body = await c.req.json<{
+      title: string;
+      genre: string;
+      language?: string;
+      chapterWordCount?: number;
+      targetChapters?: number;
+    }>();
+
+    const now = new Date().toISOString();
+    const bookId = body.title
+      .toLowerCase()
+      .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
+      .replace(/-+/g, "-")
+      .slice(0, 30);
+
+    const bookConfig = {
+      id: bookId,
+      title: body.title,
+      platform: "other" as const,
+      genre: body.genre as "xuanhuan",
+      status: "outlining" as const,
+      targetChapters: body.targetChapters ?? 200,
+      chapterWordCount: body.chapterWordCount ?? 3000,
+      ...(body.language === "en" ? { language: "en" as const } : body.language === "zh" ? { language: "zh" as const } : {}),
+      createdAt: now,
+      updatedAt: now,
+    };
+
+    broadcast("book:creating", { bookId, title: body.title });
+
+    const pipeline = new PipelineRunner(buildPipelineConfig());
+    pipeline.initBook(bookConfig).then(
+      () => broadcast("book:created", { bookId }),
+      (e) => broadcast("book:error", { bookId, error: e instanceof Error ? e.message : String(e) }),
+    );
+
+    return c.json({ status: "creating", bookId });
+  });
+
+  // --- Chapters ---
+
+  app.get("/api/books/:id/chapters/:num", async (c) => {
+    const id = c.req.param("id");
+    const num = parseInt(c.req.param("num"), 10);
+    const bookDir = state.bookDir(id);
+    const chaptersDir = join(bookDir, "chapters");
+
+    try {
+      const files = await readdir(chaptersDir);
+      const paddedNum = String(num).padStart(4, "0");
+      const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
+      if (!match) return c.json({ error: "Chapter not found" }, 404);
+      const content = await readFile(join(chaptersDir, match), "utf-8");
+      return c.json({ chapterNumber: num, filename: match, content });
+    } catch {
+      return c.json({ error: "Chapter not found" }, 404);
+    }
+  });
+
+  // --- Truth files ---
+
+  const TRUTH_FILES = [
+    "story_bible.md", "volume_outline.md", "current_state.md",
+    "particle_ledger.md", "pending_hooks.md", "chapter_summaries.md",
+    "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
+    "style_guide.md", "parent_canon.md", "fanfic_canon.md", "book_rules.md",
+  ];
+
+  app.get("/api/books/:id/truth/:file", async (c) => {
+    const id = c.req.param("id");
+    const file = c.req.param("file");
+
+    if (!TRUTH_FILES.includes(file)) {
+      return c.json({ error: "Invalid truth file" }, 400);
+    }
+
+    const bookDir = state.bookDir(id);
+    try {
+      const content = await readFile(join(bookDir, "story", file), "utf-8");
+      return c.json({ file, content });
+    } catch {
+      return c.json({ file, content: null });
+    }
+  });
+
+  // --- Analytics ---
+
+  app.get("/api/books/:id/analytics", async (c) => {
+    const id = c.req.param("id");
+    try {
+      const chapters = await state.loadChapterIndex(id);
+      return c.json(computeAnalytics(id, chapters));
+    } catch {
+      return c.json({ error: `Book "${id}" not found` }, 404);
+    }
+  });
+
+  // --- Actions ---
+
+  app.post("/api/books/:id/write-next", async (c) => {
+    const id = c.req.param("id");
+    const body = await c.req.json<{ wordCount?: number }>().catch(() => ({ wordCount: undefined }));
+
+    broadcast("write:start", { bookId: id });
+
+    // Fire and forget — progress/completion/errors pushed via SSE
+    const pipeline = new PipelineRunner(buildPipelineConfig());
+    pipeline.writeNextChapter(id, body.wordCount).then(
+      (result) => {
+        broadcast("write:complete", { bookId: id, chapterNumber: result.chapterNumber, status: result.status, title: result.title, wordCount: result.wordCount });
+      },
+      (e) => {
+        broadcast("write:error", { bookId: id, error: e instanceof Error ? e.message : String(e) });
+      },
+    );
+
+    return c.json({ status: "writing", bookId: id });
+  });
+
+  app.post("/api/books/:id/draft", async (c) => {
+    const id = c.req.param("id");
+    const body = await c.req.json<{ wordCount?: number; context?: string }>().catch(() => ({ wordCount: undefined, context: undefined }));
+
+    broadcast("draft:start", { bookId: id });
+
+    const pipeline = new PipelineRunner(buildPipelineConfig());
+    pipeline.writeDraft(id, body.context, body.wordCount).then(
+      (result) => {
+        broadcast("draft:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount });
+      },
+      (e) => {
+        broadcast("draft:error", { bookId: id, error: e instanceof Error ? e.message : String(e) });
+      },
+    );
+
+    return c.json({ status: "drafting", bookId: id });
+  });
+
+  app.post("/api/books/:id/chapters/:num/approve", async (c) => {
+    const id = c.req.param("id");
+    const num = parseInt(c.req.param("num"), 10);
+
+    try {
+      const index = await state.loadChapterIndex(id);
+      const updated = index.map((ch) =>
+        ch.number === num ? { ...ch, status: "approved" as const } : ch,
+      );
+      await state.saveChapterIndex(id, updated);
+      return c.json({ ok: true, chapterNumber: num, status: "approved" });
+    } catch (e) {
+      return c.json({ error: String(e) }, 500);
+    }
+  });
+
+  app.post("/api/books/:id/chapters/:num/reject", async (c) => {
+    const id = c.req.param("id");
+    const num = parseInt(c.req.param("num"), 10);
+
+    try {
+      const index = await state.loadChapterIndex(id);
+      const updated = index.map((ch) =>
+        ch.number === num ? { ...ch, status: "rejected" as const } : ch,
+      );
+      await state.saveChapterIndex(id, updated);
+      return c.json({ ok: true, chapterNumber: num, status: "rejected" });
+    } catch (e) {
+      return c.json({ error: String(e) }, 500);
+    }
+  });
+
+  // --- SSE ---
+
+  app.get("/api/events", (c) => {
+    return streamSSE(c, async (stream) => {
+      const handler: EventHandler = (event, data) => {
+        stream.writeSSE({ event, data: JSON.stringify(data) });
+      };
+      subscribers.add(handler);
+
+      // Keep alive
+      const keepAlive = setInterval(() => {
+        stream.writeSSE({ event: "ping", data: "" });
+      }, 30000);
+
+      stream.onAbort(() => {
+        subscribers.delete(handler);
+        clearInterval(keepAlive);
+      });
+
+      // Block until aborted
+      await new Promise(() => {});
+    });
+  });
+
+  // --- Project info ---
+
+  app.get("/api/project", (c) => {
+    return c.json({
+      name: config.name,
+      language: config.language,
+      model: config.llm.model,
+      provider: config.llm.provider,
+    });
+  });
+
+  return app;
+}
+
+// --- Standalone runner ---
+
+export async function startStudioServer(
+  root: string,
+  port = 4567,
+  options?: { readonly staticDir?: string },
+): Promise<void> {
+  const { loadProjectConfig } = await import("@actalk/inkos-core");
+  const config = await loadProjectConfig(root);
+
+  const app = createStudioServer(config, root);
+
+  // Serve frontend static files — single process for API + frontend
+  if (options?.staticDir) {
+    const { readFile: readFileFs } = await import("node:fs/promises");
+    const { join: joinPath } = await import("node:path");
+    const { existsSync } = await import("node:fs");
+
+    // Serve static assets (js, css, etc.)
+    app.get("/assets/*", async (c) => {
+      const filePath = joinPath(options.staticDir!, c.req.path);
+      try {
+        const content = await readFileFs(filePath);
+        const ext = filePath.split(".").pop() ?? "";
+        const contentTypes: Record<string, string> = {
+          js: "application/javascript",
+          css: "text/css",
+          svg: "image/svg+xml",
+          png: "image/png",
+          ico: "image/x-icon",
+          json: "application/json",
+        };
+        return new Response(content, {
+          headers: { "Content-Type": contentTypes[ext] ?? "application/octet-stream" },
+        });
+      } catch {
+        return c.notFound();
+      }
+    });
+
+    // SPA fallback — serve index.html for all non-API routes
+    const indexPath = joinPath(options.staticDir!, "index.html");
+    if (existsSync(indexPath)) {
+      const indexHtml = await readFileFs(indexPath, "utf-8");
+      app.get("*", (c) => {
+        if (c.req.path.startsWith("/api/")) return c.notFound();
+        return c.html(indexHtml);
+      });
+    }
+  }
+
+  console.log(`InkOS Studio running on http://localhost:${port}`);
+  serve({ fetch: app.fetch, port });
+}

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

@@ -0,0 +1,41 @@
+import { useState, useEffect, useCallback } from "react";
+
+const BASE = "/api";
+
+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 () => {
+    setLoading(true);
+    setError(null);
+    try {
+      const res = await fetch(`${BASE}${path}`);
+      if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
+      const json = await res.json();
+      setData(json as T);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : String(e));
+    } finally {
+      setLoading(false);
+    }
+  }, [path]);
+
+  useEffect(() => {
+    refetch();
+  }, [refetch]);
+
+  return { data, loading, error, refetch };
+}
+
+export async function postApi<T>(path: string, body?: unknown): Promise<T> {
+  const res = await fetch(`${BASE}${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;
+}

+ 38 - 0
packages/studio/src/hooks/use-colors.ts

@@ -0,0 +1,38 @@
+import type { Theme } from "./use-theme";
+
+export function useColors(theme: Theme) {
+  const d = theme === "dark";
+  return {
+    card: d ? "border-zinc-700 hover:border-zinc-600 transition-colors" : "border-zinc-200 hover:border-zinc-400 transition-colors",
+    cardStatic: d ? "border-zinc-700" : "border-zinc-200",
+    surface: d ? "bg-zinc-900" : "bg-white",
+    muted: d ? "text-zinc-500" : "text-zinc-400",
+    subtle: d ? "text-zinc-400" : "text-zinc-500",
+    link: d ? "hover:text-white transition-colors cursor-pointer" : "hover:text-zinc-900 transition-colors cursor-pointer",
+    input: d
+      ? "bg-zinc-800 border border-zinc-600 text-zinc-100 focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-colors"
+      : "bg-white border border-zinc-300 text-zinc-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-colors",
+    btnPrimary: d
+      ? "bg-blue-600 text-white hover:bg-blue-500 active:bg-blue-700 transition-colors"
+      : "bg-blue-600 text-white hover:bg-blue-700 active:bg-blue-800 transition-colors",
+    btnSecondary: d
+      ? "bg-zinc-800 text-zinc-300 hover:bg-zinc-700 hover:text-zinc-100 active:bg-zinc-600 transition-colors"
+      : "bg-zinc-100 text-zinc-700 hover:bg-zinc-200 hover:text-zinc-900 active:bg-zinc-300 transition-colors",
+    btnSuccess: d
+      ? "bg-emerald-700 text-emerald-100 hover:bg-emerald-600 active:bg-emerald-800 transition-colors"
+      : "bg-emerald-600 text-white hover:bg-emerald-700 active:bg-emerald-800 transition-colors",
+    btnDanger: d
+      ? "bg-red-800 text-red-100 hover:bg-red-700 active:bg-red-900 transition-colors"
+      : "bg-red-600 text-white hover:bg-red-700 active:bg-red-800 transition-colors",
+    tableHeader: d ? "bg-zinc-900 text-zinc-400" : "bg-zinc-50 text-zinc-500",
+    tableDivide: d ? "divide-zinc-700" : "divide-zinc-200",
+    tableHover: d ? "hover:bg-zinc-800/50 transition-colors" : "hover:bg-zinc-50 transition-colors",
+    error: d ? "border-red-700 bg-red-950/40 text-red-300" : "border-red-200 bg-red-50 text-red-700",
+    info: d ? "border-blue-700 bg-blue-950/40 text-blue-300" : "border-blue-200 bg-blue-50 text-blue-700",
+    code: d ? "bg-zinc-800 text-zinc-300" : "bg-zinc-100 text-zinc-700",
+    active: "text-emerald-400",
+    paused: "text-amber-400",
+    mono: "font-mono text-sm",
+    accent: d ? "text-blue-400" : "text-blue-600",
+  };
+}

+ 89 - 0
packages/studio/src/hooks/use-i18n.ts

@@ -0,0 +1,89 @@
+import { useApi } from "./use-api";
+
+type Lang = "zh" | "en";
+
+const strings = {
+  // Header
+  "nav.books": { zh: "书籍", en: "Books" },
+  "nav.newBook": { zh: "新建书籍", en: "New Book" },
+  "nav.config": { zh: "配置", en: "Config" },
+  "nav.connected": { zh: "已连接", en: "Connected" },
+  "nav.disconnected": { zh: "未连接", en: "Disconnected" },
+
+  // Dashboard
+  "dash.title": { zh: "书籍列表", en: "Books" },
+  "dash.noBooks": { zh: "还没有书", en: "No books yet" },
+  "dash.createFirst": { zh: "创建第一本书开始写作", en: "Create your first book to get started" },
+  "dash.writeNext": { zh: "写下一章", en: "Write Next" },
+  "dash.writing": { zh: "写作中...", en: "Writing..." },
+  "dash.stats": { zh: "统计", en: "Stats" },
+  "dash.chapters": { zh: "章", en: "chapters" },
+  "dash.recentEvents": { zh: "最近事件", en: "Recent Events" },
+  "dash.writingProgress": { zh: "写作进度", en: "Writing Progress" },
+
+  // Book Detail
+  "book.writeNext": { zh: "写下一章", en: "Write Next" },
+  "book.draftOnly": { zh: "仅草稿", en: "Draft Only" },
+  "book.approveAll": { zh: "全部通过", en: "Approve All" },
+  "book.analytics": { zh: "数据分析", en: "Analytics" },
+  "book.noChapters": { zh: "暂无章节,点击「写下一章」开始", en: 'No chapters yet. Click "Write Next" to start.' },
+  "book.approve": { zh: "通过", en: "Approve" },
+  "book.reject": { zh: "驳回", en: "Reject" },
+  "book.words": { zh: "字", en: "words" },
+
+  // Chapter Reader
+  "reader.backToList": { zh: "返回列表", en: "Back to List" },
+  "reader.approve": { zh: "通过", en: "Approve" },
+  "reader.reject": { zh: "驳回", en: "Reject" },
+  "reader.chapterList": { zh: "章节列表", en: "Chapter List" },
+  "reader.characters": { zh: "字符", en: "characters" },
+
+  // Book Create
+  "create.title": { zh: "创建书籍", en: "Create Book" },
+  "create.bookTitle": { zh: "书名", en: "Title" },
+  "create.language": { zh: "语言", en: "Language" },
+  "create.genre": { zh: "题材", en: "Genre" },
+  "create.wordsPerChapter": { zh: "每章字数", en: "Words / Chapter" },
+  "create.targetChapters": { zh: "目标章数", en: "Target Chapters" },
+  "create.creating": { zh: "创建中...", en: "Creating..." },
+  "create.submit": { zh: "创建书籍", en: "Create Book" },
+  "create.titleRequired": { zh: "请输入书名", en: "Title is required" },
+  "create.genreRequired": { zh: "请选择题材", en: "Genre is required" },
+  "create.placeholder": { zh: "请输入书名...", en: "Book title..." },
+
+  // Analytics
+  "analytics.title": { zh: "数据分析", en: "Analytics" },
+  "analytics.totalChapters": { zh: "总章数", en: "Total Chapters" },
+  "analytics.totalWords": { zh: "总字数", en: "Total Words" },
+  "analytics.avgWords": { zh: "平均字数/章", en: "Avg Words/Chapter" },
+  "analytics.statusDist": { zh: "状态分布", en: "Status Distribution" },
+
+  // Breadcrumb
+  "bread.books": { zh: "书籍", en: "Books" },
+  "bread.newBook": { zh: "新建书籍", en: "New Book" },
+  "bread.config": { zh: "配置", en: "Config" },
+  "bread.home": { zh: "首页", en: "Home" },
+  "bread.chapter": { zh: "第{n}章", en: "Chapter {n}" },
+
+  // Config
+  "config.title": { zh: "项目配置", en: "Project Config" },
+  "config.project": { zh: "项目名", en: "Project" },
+  "config.language": { zh: "语言", en: "Language" },
+  "config.provider": { zh: "提供方", en: "Provider" },
+  "config.model": { zh: "模型", en: "Model" },
+  "config.editHint": { zh: "通过 CLI 编辑配置:", en: "Edit via CLI:" },
+} as const;
+
+export type StringKey = keyof typeof strings;
+export type TFunction = (key: StringKey) => string;
+
+export function useI18n() {
+  const { data } = useApi<{ language: string }>("/project");
+  const lang: Lang = data?.language === "en" ? "en" : "zh";
+
+  function t(key: StringKey): string {
+    return strings[key][lang];
+  }
+
+  return { t, lang };
+}

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

@@ -0,0 +1,48 @@
+import { useEffect, useRef, useCallback, useState } from "react";
+
+export interface SSEMessage {
+  readonly event: string;
+  readonly data: unknown;
+  readonly timestamp: number;
+}
+
+export function useSSE(url = "/api/events") {
+  const [messages, setMessages] = useState<ReadonlyArray<SSEMessage>>([]);
+  const [connected, setConnected] = useState(false);
+  const esRef = useRef<EventSource | null>(null);
+
+  useEffect(() => {
+    const es = new EventSource(url);
+    esRef.current = es;
+
+    es.onopen = () => setConnected(true);
+    es.onerror = () => setConnected(false);
+
+    const handleEvent = (e: MessageEvent) => {
+      try {
+        const data = e.data ? JSON.parse(e.data) : null;
+        setMessages((prev) => [...prev.slice(-99), { event: e.type, data, timestamp: Date.now() }]);
+      } catch {
+        // ignore parse errors
+      }
+    };
+
+    const events = [
+      "write:start", "write:complete", "write:error",
+      "draft:start", "draft:complete", "draft:error",
+      "log", "llm:progress", "ping",
+    ];
+    for (const event of events) {
+      es.addEventListener(event, handleEvent);
+    }
+
+    return () => {
+      es.close();
+      esRef.current = null;
+    };
+  }, [url]);
+
+  const clear = useCallback(() => setMessages([]), []);
+
+  return { messages, connected, clear };
+}

+ 22 - 0
packages/studio/src/hooks/use-theme.ts

@@ -0,0 +1,22 @@
+import { useState, useEffect } from "react";
+
+export type Theme = "light" | "dark";
+
+function getTimeBasedTheme(): Theme {
+  const hour = new Date().getHours();
+  return hour >= 6 && hour < 18 ? "light" : "dark";
+}
+
+export function useTheme() {
+  const [theme, setTheme] = useState<Theme>(getTimeBasedTheme);
+
+  useEffect(() => {
+    // Check every minute for time-based switch
+    const timer = setInterval(() => {
+      setTheme(getTimeBasedTheme());
+    }, 60000);
+    return () => clearInterval(timer);
+  }, []);
+
+  return { theme, setTheme };
+}

+ 21 - 0
packages/studio/src/index.css

@@ -0,0 +1,21 @@
+@import "tailwindcss";
+
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
+
+body {
+  font-family: 'Inter', system-ui, -apple-system, sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+/* Smooth theme transitions */
+* {
+  transition-property: background-color, border-color, color;
+  transition-duration: 150ms;
+  transition-timing-function: ease-out;
+}
+
+/* Override for elements that shouldn't animate */
+input, textarea, select {
+  transition: border-color 150ms ease-out, box-shadow 150ms ease-out;
+}

+ 10 - 0
packages/studio/src/main.tsx

@@ -0,0 +1,10 @@
+import "./index.css";
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./App";
+
+createRoot(document.getElementById("root")!).render(
+  <StrictMode>
+    <App />
+  </StrictMode>,
+);

+ 80 - 0
packages/studio/src/pages/Analytics.tsx

@@ -0,0 +1,80 @@
+import { useApi } from "../hooks/use-api";
+import type { Theme } from "../hooks/use-theme";
+import type { TFunction } from "../hooks/use-i18n";
+import { useColors } from "../hooks/use-colors";
+
+interface AnalyticsData {
+  readonly bookId: string;
+  readonly totalChapters: number;
+  readonly totalWords: number;
+  readonly avgWordsPerChapter: number;
+  readonly statusDistribution: Record<string, number>;
+}
+
+interface Nav {
+  toBook: (id: string) => void;
+  toDashboard: () => void;
+}
+
+export function Analytics({ bookId, nav, theme, t }: { bookId: string; nav: Nav; theme: Theme; t: TFunction }) {
+  const c = useColors(theme);
+  const { data, loading, error } = useApi<AnalyticsData>(`/books/${bookId}/analytics`);
+
+  if (loading) return <div className={c.muted}>Loading...</div>;
+  if (error) return <div className="text-red-400">Error: {error}</div>;
+  if (!data) return null;
+
+  const statuses = Object.entries(data.statusDistribution);
+  const totalFromDist = statuses.reduce((sum, [, count]) => sum + count, 0);
+
+  return (
+    <div className="space-y-6">
+      <div className={`flex items-center gap-2 text-sm ${c.muted}`}>
+        <button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
+        <span>/</span>
+        <button onClick={() => nav.toBook(bookId)} className={c.link}>{bookId}</button>
+        <span>/</span>
+        <span className={c.subtle}>{t("analytics.title")}</span>
+      </div>
+
+      <h1 className="text-2xl font-semibold">{t("analytics.title")}</h1>
+
+      <div className="grid grid-cols-3 gap-4">
+        <StatCard label={t("analytics.totalChapters")} value={data.totalChapters.toString()} c={c} />
+        <StatCard label={t("analytics.totalWords")} value={data.totalWords.toLocaleString()} c={c} />
+        <StatCard label={t("analytics.avgWords")} value={data.avgWordsPerChapter.toLocaleString()} c={c} />
+      </div>
+
+      {statuses.length > 0 && (
+        <div className={`border ${c.cardStatic} rounded-lg p-5`}>
+          <h2 className={`text-sm font-medium ${c.subtle} mb-4`}>{t("analytics.statusDist")}</h2>
+          <div className="space-y-3">
+            {statuses.map(([status, count]) => (
+              <div key={status}>
+                <div className="flex justify-between text-sm mb-1">
+                  <span className={c.subtle}>{status}</span>
+                  <span className={c.muted}>{count}</span>
+                </div>
+                <div className={`h-2 ${c.btnSecondary} rounded-full overflow-hidden`}>
+                  <div
+                    className="h-full bg-zinc-500 rounded-full transition-all"
+                    style={{ width: `${totalFromDist > 0 ? (count / totalFromDist) * 100 : 0}%` }}
+                  />
+                </div>
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+
+function StatCard({ label, value, c }: { label: string; value: string; c: ReturnType<typeof useColors> }) {
+  return (
+    <div className={`border ${c.cardStatic} rounded-lg p-5`}>
+      <div className={`text-sm ${c.muted} mb-1`}>{label}</div>
+      <div className="text-2xl font-semibold tabular-nums">{value}</div>
+    </div>
+  );
+}

+ 156 - 0
packages/studio/src/pages/BookCreate.tsx

@@ -0,0 +1,156 @@
+import { useState } from "react";
+import { useApi, postApi } from "../hooks/use-api";
+import type { Theme } from "../hooks/use-theme";
+import type { TFunction } from "../hooks/use-i18n";
+import { useColors } from "../hooks/use-colors";
+
+interface Nav {
+  toDashboard: () => void;
+  toBook: (id: string) => void;
+}
+
+interface GenreInfo {
+  readonly id: string;
+  readonly name: string;
+  readonly source: "project" | "builtin";
+}
+
+export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
+  const c = useColors(theme);
+  const { data: genreData } = useApi<{ genres: ReadonlyArray<GenreInfo> }>("/genres");
+
+  const [title, setTitle] = useState("");
+  const [genre, setGenre] = useState("");
+  const [language, setLanguage] = useState<"zh" | "en">("zh");
+  const [chapterWords, setChapterWords] = useState("3000");
+  const [targetChapters, setTargetChapters] = useState("200");
+  const [creating, setCreating] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const genres = genreData?.genres ?? [];
+
+  // Auto-select first genre when data loads
+  if (genres.length > 0 && !genre) {
+    setGenre(genres[0]!.id);
+  }
+
+  const handleCreate = async () => {
+    if (!title.trim()) {
+      setError(t("create.titleRequired"));
+      return;
+    }
+    if (!genre) {
+      setError(t("create.genreRequired"));
+      return;
+    }
+    setCreating(true);
+    setError(null);
+    try {
+      const result = await postApi<{ bookId: string }>("/books/create", {
+        title: title.trim(),
+        genre,
+        language,
+        chapterWordCount: parseInt(chapterWords, 10),
+        targetChapters: parseInt(targetChapters, 10),
+      });
+      nav.toBook(result.bookId);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : "Failed to create book");
+    } finally {
+      setCreating(false);
+    }
+  };
+
+  return (
+    <div className="max-w-lg mx-auto space-y-6">
+      <div className={`flex items-center gap-2 text-sm ${c.muted}`}>
+        <button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
+        <span>/</span>
+        <span className={c.subtle}>{t("bread.newBook")}</span>
+      </div>
+
+      <h1 className="text-2xl font-semibold">{t("create.title")}</h1>
+
+      {error && (
+        <div className={`border ${c.error} rounded-lg px-4 py-3 text-sm`}>
+          {error}
+        </div>
+      )}
+
+      <div className="space-y-4">
+        <div>
+          <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.bookTitle")}</label>
+          <input
+            type="text"
+            value={title}
+            onChange={(e) => setTitle(e.target.value)}
+            className={`w-full ${c.input} rounded-md px-3 py-2 focus:outline-none`}
+            placeholder={t("create.placeholder")}
+          />
+        </div>
+
+        <div>
+          <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.language")}</label>
+          <div className="flex gap-2">
+            <button
+              onClick={() => setLanguage("zh")}
+              className={`px-4 py-2 rounded-md text-sm ${language === "zh" ? c.btnPrimary : c.btnSecondary}`}
+            >
+              Chinese
+            </button>
+            <button
+              onClick={() => setLanguage("en")}
+              className={`px-4 py-2 rounded-md text-sm ${language === "en" ? c.btnPrimary : c.btnSecondary}`}
+            >
+              English
+            </button>
+          </div>
+        </div>
+
+        <div>
+          <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.genre")}</label>
+          <select
+            value={genre}
+            onChange={(e) => setGenre(e.target.value)}
+            className={`w-full ${c.input} rounded-md px-3 py-2 focus:outline-none`}
+          >
+            {genres.map((g) => (
+              <option key={g.id} value={g.id}>
+                {g.name} ({g.id}){g.source === "project" ? " [custom]" : ""}
+              </option>
+            ))}
+          </select>
+        </div>
+
+        <div className="grid grid-cols-2 gap-4">
+          <div>
+            <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.wordsPerChapter")}</label>
+            <input
+              type="number"
+              value={chapterWords}
+              onChange={(e) => setChapterWords(e.target.value)}
+              className={`w-full ${c.input} rounded-md px-3 py-2 focus:outline-none`}
+            />
+          </div>
+          <div>
+            <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.targetChapters")}</label>
+            <input
+              type="number"
+              value={targetChapters}
+              onChange={(e) => setTargetChapters(e.target.value)}
+              className={`w-full ${c.input} rounded-md px-3 py-2 focus:outline-none`}
+            />
+          </div>
+        </div>
+      </div>
+
+      <button
+        onClick={handleCreate}
+        disabled={creating || !title.trim()}
+        className={`w-full px-4 py-2.5 ${c.btnPrimary} rounded-md transition-colors disabled:opacity-50 font-medium`}
+      >
+        {creating ? t("create.creating") : t("create.submit")}
+      </button>
+    </div>
+  );
+}

+ 199 - 0
packages/studio/src/pages/BookDetail.tsx

@@ -0,0 +1,199 @@
+import { useApi, postApi } from "../hooks/use-api";
+import { useState } from "react";
+import type { Theme } from "../hooks/use-theme";
+import type { TFunction } from "../hooks/use-i18n";
+import { useColors } from "../hooks/use-colors";
+
+interface ChapterMeta {
+  readonly number: number;
+  readonly title: string;
+  readonly status: string;
+  readonly wordCount: number;
+}
+
+interface BookData {
+  readonly book: {
+    readonly id: string;
+    readonly title: string;
+    readonly genre: string;
+    readonly status: string;
+    readonly chapterWordCount: number;
+    readonly language?: string;
+    readonly fanficMode?: string;
+  };
+  readonly chapters: ReadonlyArray<ChapterMeta>;
+  readonly nextChapter: number;
+}
+
+interface Nav {
+  toDashboard: () => void;
+  toChapter: (bookId: string, num: number) => void;
+  toAnalytics: (bookId: string) => void;
+}
+
+const STATUS_COLORS: Record<string, string> = {
+  "ready-for-review": "text-amber-400",
+  approved: "text-emerald-400",
+  drafted: "text-zinc-400",
+  "needs-revision": "text-red-400",
+  imported: "text-blue-400",
+};
+
+export function BookDetail({ bookId, nav, theme, t }: { bookId: string; nav: Nav; theme: Theme; t: TFunction }) {
+  const c = useColors(theme);
+  const { data, loading, error, refetch } = useApi<BookData>(`/books/${bookId}`);
+  const [writing, setWriting] = useState(false);
+  const [drafting, setDrafting] = useState(false);
+
+  const handleWriteNext = async () => {
+    setWriting(true);
+    try {
+      await postApi(`/books/${bookId}/write-next`);
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Failed");
+    } finally {
+      setWriting(false);
+    }
+  };
+
+  const handleDraft = async () => {
+    setDrafting(true);
+    try {
+      await postApi(`/books/${bookId}/draft`);
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Failed");
+    } finally {
+      setDrafting(false);
+    }
+  };
+
+  const handleApproveAll = async () => {
+    if (!data) return;
+    const reviewable = data.chapters.filter((ch) => ch.status === "ready-for-review");
+    for (const ch of reviewable) {
+      await postApi(`/books/${bookId}/chapters/${ch.number}/approve`);
+    }
+    refetch();
+  };
+
+  if (loading) return <div className={c.muted}>Loading...</div>;
+  if (error) return <div className="text-red-400">Error: {error}</div>;
+  if (!data) return null;
+
+  const { book, chapters } = data;
+  const totalWords = chapters.reduce((sum, ch) => sum + (ch.wordCount ?? 0), 0);
+  const reviewCount = chapters.filter((ch) => ch.status === "ready-for-review").length;
+
+  return (
+    <div className="space-y-6">
+      <div className={`flex items-center gap-2 text-sm ${c.muted}`}>
+        <button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
+        <span>/</span>
+        <span className={c.subtle}>{book.title}</span>
+      </div>
+
+      <div className="flex items-start justify-between">
+        <div>
+          <h1 className="text-2xl font-semibold">{book.title}</h1>
+          <div className={`flex gap-3 mt-1 text-sm ${c.muted}`}>
+            <span>{book.genre}</span>
+            <span>{chapters.length} chapters</span>
+            <span>{totalWords.toLocaleString()} words</span>
+            {book.language === "en" && <span className="text-blue-400">EN</span>}
+            {book.fanficMode && <span className="text-purple-400">fanfic:{book.fanficMode}</span>}
+          </div>
+        </div>
+
+        <div className="flex gap-2">
+          <button
+            onClick={handleWriteNext}
+            disabled={writing || drafting}
+            className={`px-4 py-2 text-sm ${c.btnPrimary} rounded-md transition-colors disabled:opacity-50`}
+          >
+            {writing ? "Writing..." : t("book.writeNext")}
+          </button>
+          <button
+            onClick={handleDraft}
+            disabled={writing || drafting}
+            className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors disabled:opacity-50`}
+          >
+            {drafting ? "Drafting..." : t("book.draftOnly")}
+          </button>
+          {reviewCount > 0 && (
+            <button
+              onClick={handleApproveAll}
+              className={`px-4 py-2 text-sm ${c.btnSuccess} rounded-md transition-colors`}
+            >
+              {t("book.approveAll")} ({reviewCount})
+            </button>
+          )}
+          <button
+            onClick={() => nav.toAnalytics(bookId)}
+            className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors`}
+          >
+            {t("book.analytics")}
+          </button>
+        </div>
+      </div>
+
+      <div className={`border ${c.cardStatic} rounded-lg overflow-hidden`}>
+        <table className="w-full text-sm">
+          <thead className={c.tableHeader}>
+            <tr>
+              <th className="text-left px-4 py-3 font-medium w-16">#</th>
+              <th className="text-left px-4 py-3 font-medium">Title</th>
+              <th className="text-left px-4 py-3 font-medium w-24">Words</th>
+              <th className="text-left px-4 py-3 font-medium w-32">Status</th>
+              <th className="text-right px-4 py-3 font-medium w-32">Actions</th>
+            </tr>
+          </thead>
+          <tbody className={`divide-y ${c.tableDivide}`}>
+            {chapters.map((ch) => (
+              <tr key={ch.number} className={`${c.tableHover} transition-colors`}>
+                <td className={`px-4 py-3 ${c.muted}`}>{ch.number}</td>
+                <td className="px-4 py-3">
+                  <button
+                    onClick={() => nav.toChapter(bookId, ch.number)}
+                    className={`${c.link} transition-colors`}
+                  >
+                    {ch.title || `Chapter ${ch.number}`}
+                  </button>
+                </td>
+                <td className={`px-4 py-3 ${c.subtle} tabular-nums`}>{(ch.wordCount ?? 0).toLocaleString()}</td>
+                <td className={`px-4 py-3 ${STATUS_COLORS[ch.status] ?? c.subtle}`}>
+                  {ch.status}
+                </td>
+                <td className="px-4 py-3 text-right">
+                  {ch.status === "ready-for-review" && (
+                    <div className="flex gap-1 justify-end">
+                      <button
+                        onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/approve`); refetch(); }}
+                        className={`px-2 py-1 text-xs ${c.btnSuccess} rounded`}
+                      >
+                        {t("book.approve")}
+                      </button>
+                      <button
+                        onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/reject`); refetch(); }}
+                        className={`px-2 py-1 text-xs ${c.btnDanger} rounded`}
+                      >
+                        {t("book.reject")}
+                      </button>
+                    </div>
+                  )}
+                </td>
+              </tr>
+            ))}
+          </tbody>
+        </table>
+
+        {chapters.length === 0 && (
+          <div className={`text-center py-12 ${c.muted}`}>
+            {t("book.noChapters")}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 116 - 0
packages/studio/src/pages/ChapterReader.tsx

@@ -0,0 +1,116 @@
+import { useApi, postApi } from "../hooks/use-api";
+import type { Theme } from "../hooks/use-theme";
+import type { TFunction } from "../hooks/use-i18n";
+import { useColors } from "../hooks/use-colors";
+
+interface ChapterData {
+  readonly chapterNumber: number;
+  readonly filename: string;
+  readonly content: string;
+}
+
+interface Nav {
+  toBook: (id: string) => void;
+  toDashboard: () => void;
+}
+
+export function ChapterReader({ bookId, chapterNumber, nav, theme, t }: {
+  bookId: string;
+  chapterNumber: number;
+  nav: Nav;
+  theme: Theme;
+  t: TFunction;
+}) {
+  const c = useColors(theme);
+  const { data, loading, error } = useApi<ChapterData>(
+    `/books/${bookId}/chapters/${chapterNumber}`,
+  );
+
+  if (loading) return <div className={c.muted}>Loading...</div>;
+  if (error) return <div className="text-red-400">Error: {error}</div>;
+  if (!data) return null;
+
+  // Split markdown content into title and body
+  const lines = data.content.split("\n");
+  const titleLine = lines.find((l) => l.startsWith("# "));
+  const title = titleLine?.replace(/^#\s*/, "") ?? `Chapter ${chapterNumber}`;
+  const body = lines
+    .filter((l) => l !== titleLine)
+    .join("\n")
+    .trim();
+
+  const handleApprove = async () => {
+    await postApi(`/books/${bookId}/chapters/${chapterNumber}/approve`);
+    nav.toBook(bookId);
+  };
+
+  const handleReject = async () => {
+    await postApi(`/books/${bookId}/chapters/${chapterNumber}/reject`);
+    nav.toBook(bookId);
+  };
+
+  // Simple paragraph rendering
+  const paragraphs = body.split(/\n\n+/).filter(Boolean);
+
+  return (
+    <div className="max-w-3xl mx-auto space-y-6">
+      <div className={`flex items-center gap-2 text-sm ${c.muted}`}>
+        <button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
+        <span>/</span>
+        <button onClick={() => nav.toBook(bookId)} className={c.link}>{bookId}</button>
+        <span>/</span>
+        <span className={c.subtle}>{t("bread.chapter").replace("{n}", String(chapterNumber))}</span>
+      </div>
+
+      <div className="flex items-center justify-between">
+        <h1 className="text-2xl font-semibold">{title}</h1>
+        <div className="flex gap-2">
+          {chapterNumber > 1 && (
+            <button
+              onClick={() => nav.toBook(bookId)}
+              className={`px-3 py-1.5 text-sm ${c.btnSecondary} rounded-md`}
+            >
+              {t("reader.backToList")}
+            </button>
+          )}
+          <button
+            onClick={handleApprove}
+            className={`px-3 py-1.5 text-sm ${c.btnSuccess} rounded-md`}
+          >
+            {t("reader.approve")}
+          </button>
+          <button
+            onClick={handleReject}
+            className={`px-3 py-1.5 text-sm ${c.btnDanger} rounded-md`}
+          >
+            {t("reader.reject")}
+          </button>
+        </div>
+      </div>
+
+      <article className="prose prose-invert prose-zinc max-w-none">
+        {paragraphs.map((para, i) => (
+          <p key={i} className={`${c.subtle} leading-relaxed text-base mb-4`}>
+            {para}
+          </p>
+        ))}
+      </article>
+
+      <div className={`flex justify-between pt-8 border-t ${c.cardStatic} text-sm`}>
+        {chapterNumber > 1 ? (
+          <button
+            onClick={() => nav.toBook(bookId)}
+            className={`${c.subtle} ${c.link}`}
+          >
+            {t("reader.chapterList")}
+          </button>
+        ) : (
+          <div />
+        )}
+        <div className={c.muted}>
+          {body.length.toLocaleString()} {t("reader.characters")}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 56 - 0
packages/studio/src/pages/ConfigView.tsx

@@ -0,0 +1,56 @@
+import { useApi } from "../hooks/use-api";
+import type { Theme } from "../hooks/use-theme";
+import type { TFunction } from "../hooks/use-i18n";
+import { useColors } from "../hooks/use-colors";
+
+interface ProjectInfo {
+  readonly name: string;
+  readonly language: string;
+  readonly model: string;
+  readonly provider: string;
+}
+
+interface Nav {
+  toDashboard: () => void;
+}
+
+export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
+  const c = useColors(theme);
+  const { data, loading, error } = useApi<ProjectInfo>("/project");
+
+  if (loading) return <div className={c.muted}>Loading...</div>;
+  if (error) return <div className="text-red-400">Error: {error}</div>;
+  if (!data) return null;
+
+  return (
+    <div className="max-w-lg mx-auto space-y-6">
+      <div className={`flex items-center gap-2 text-sm ${c.muted}`}>
+        <button onClick={nav.toDashboard} className={c.link}>{t("bread.home")}</button>
+        <span>/</span>
+        <span className={c.subtle}>{t("bread.config")}</span>
+      </div>
+
+      <h1 className="text-2xl font-semibold">{t("config.title")}</h1>
+
+      <div className={`border ${c.cardStatic} rounded-lg divide-y ${c.tableDivide}`}>
+        <Row label={t("config.project")} value={data.name} c={c} />
+        <Row label={t("config.language")} value={data.language === "en" ? "English" : "Chinese"} c={c} />
+        <Row label={t("config.provider")} value={data.provider} c={c} />
+        <Row label={t("config.model")} value={data.model} c={c} />
+      </div>
+
+      <p className={`text-sm ${c.muted}`}>
+        {t("config.editHint")} <code className={`${c.code} px-1.5 py-0.5 rounded`}>inkos config set &lt;key&gt; &lt;value&gt;</code>
+      </p>
+    </div>
+  );
+}
+
+function Row({ label, value, c }: { label: string; value: string; c: ReturnType<typeof useColors> }) {
+  return (
+    <div className="flex justify-between px-4 py-3">
+      <span className={c.subtle}>{label}</span>
+      <span className={c.mono}>{value}</span>
+    </div>
+  );
+}

+ 142 - 0
packages/studio/src/pages/Dashboard.tsx

@@ -0,0 +1,142 @@
+import { useApi, postApi } from "../hooks/use-api";
+import { useState, useMemo } from "react";
+import type { SSEMessage } from "../hooks/use-sse";
+import type { Theme } from "../hooks/use-theme";
+import type { TFunction } from "../hooks/use-i18n";
+import { useColors } from "../hooks/use-colors";
+
+interface BookSummary {
+  readonly id: string;
+  readonly title: string;
+  readonly genre: string;
+  readonly status: string;
+  readonly chaptersWritten: number;
+  readonly language?: string;
+  readonly fanficMode?: string;
+}
+
+interface Nav {
+  toBook: (id: string) => void;
+  toAnalytics: (id: string) => void;
+  toBookCreate: () => void;
+}
+
+export function Dashboard({ nav, sse, theme, t }: { nav: Nav; sse: { messages: ReadonlyArray<SSEMessage> }; theme: Theme; t: TFunction }) {
+  const c = useColors(theme);
+  const { data, loading, error, refetch } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
+  const [writingBooks, setWritingBooks] = useState<Set<string>>(new Set());
+
+  const recentEvents = sse.messages.filter((m) => m.event !== "ping" && m.event !== "llm:progress");
+  const logEvents = sse.messages.filter((m) => m.event === "log").slice(-8);
+  const progressEvent = sse.messages.filter((m) => m.event === "llm:progress").slice(-1)[0];
+
+  useMemo(() => {
+    for (const msg of sse.messages) {
+      const bookId = (msg.data as { bookId?: string })?.bookId;
+      if (!bookId) continue;
+      if (msg.event === "write:start" || msg.event === "draft:start") {
+        setWritingBooks((prev) => new Set([...prev, bookId]));
+      }
+      if (msg.event === "write:complete" || msg.event === "write:error" ||
+          msg.event === "draft:complete" || msg.event === "draft:error") {
+        setWritingBooks((prev) => { const next = new Set(prev); next.delete(bookId); return next; });
+        refetch();
+      }
+    }
+  }, [sse.messages.length]);
+
+  if (loading) return <div className={c.muted}>Loading...</div>;
+  if (error) return <div className="text-red-400">Error: {error}</div>;
+  if (!data?.books.length) {
+    return (
+      <div className={`text-center py-20 ${c.muted}`}>
+        <p className="text-xl mb-2">{t("dash.noBooks")}</p>
+        <p className="text-sm mb-4">{t("dash.createFirst")}</p>
+        <button onClick={nav.toBookCreate} className={`px-4 py-2 rounded-md text-sm ${c.btnPrimary}`}>
+          {t("nav.newBook")}
+        </button>
+      </div>
+    );
+  }
+
+  return (
+    <div className="space-y-8">
+      <div className="flex items-center justify-between">
+        <h1 className="text-2xl font-semibold">{t("dash.title")}</h1>
+        <button onClick={nav.toBookCreate} className={`px-3 py-1.5 text-sm rounded-md ${c.btnPrimary}`}>
+          {t("nav.newBook")}
+        </button>
+      </div>
+
+      <div className="grid gap-4">
+        {data.books.map((book) => {
+          const isWriting = writingBooks.has(book.id);
+          return (
+            <div key={book.id} className={`border rounded-lg p-5 transition-colors ${c.card}`}>
+              <div className="flex items-start justify-between">
+                <div>
+                  <button onClick={() => nav.toBook(book.id)} className={`text-lg font-medium ${c.link} transition-colors text-left`}>
+                    {book.title}
+                  </button>
+                  <div className={`flex gap-3 mt-1 text-sm ${c.muted}`}>
+                    <span>{book.genre}</span>
+                    <span>{book.chaptersWritten} {t("dash.chapters")}</span>
+                    <span className={book.status === "active" ? c.active : book.status === "paused" ? c.paused : c.subtle}>
+                      {book.status}
+                    </span>
+                    {book.language === "en" && <span className="text-blue-400">EN</span>}
+                    {book.fanficMode && <span className="text-purple-400">fanfic:{book.fanficMode}</span>}
+                  </div>
+                </div>
+                <div className="flex gap-2">
+                  <button
+                    onClick={() => postApi(`/books/${book.id}/write-next`)}
+                    disabled={isWriting}
+                    className={`px-3 py-1.5 text-sm rounded-md transition-colors disabled:opacity-50 disabled:cursor-wait ${c.btnSecondary}`}
+                  >
+                    {isWriting ? t("dash.writing") : t("dash.writeNext")}
+                  </button>
+                  <button onClick={() => nav.toAnalytics(book.id)} className={`px-3 py-1.5 text-sm rounded-md transition-colors ${c.btnSecondary}`}>
+                    {t("dash.stats")}
+                  </button>
+                </div>
+              </div>
+            </div>
+          );
+        })}
+      </div>
+
+      {writingBooks.size > 0 && logEvents.length > 0 && (
+        <div className={`border rounded-lg p-4 ${c.info}`}>
+          <h2 className="text-sm font-medium mb-3">{t("dash.writingProgress")}</h2>
+          <div className={`space-y-1 text-sm ${c.mono} ${c.subtle}`}>
+            {logEvents.map((msg, i) => {
+              const d = msg.data as { tag?: string; message?: string };
+              return <div key={i}><span className={c.muted}>[{d.tag}]</span> {d.message}</div>;
+            })}
+            {progressEvent && (
+              <div className="text-blue-400 mt-2">
+                LLM streaming: {Math.round(((progressEvent.data as { elapsedMs?: number })?.elapsedMs ?? 0) / 1000)}s,{" "}
+                {((progressEvent.data as { totalChars?: number })?.totalChars ?? 0).toLocaleString()} chars
+              </div>
+            )}
+          </div>
+        </div>
+      )}
+
+      {writingBooks.size === 0 && recentEvents.length > 0 && (
+        <div className={`border rounded-lg p-4 ${c.cardStatic}`}>
+          <h2 className={`text-sm font-medium ${c.subtle} mb-3`}>{t("dash.recentEvents")}</h2>
+          <div className={`space-y-1 text-sm ${c.mono} ${c.muted}`}>
+            {recentEvents.slice(-5).map((msg, i) => (
+              <div key={i}>
+                <span className={c.muted}>{new Date(msg.timestamp).toLocaleTimeString()}</span>{" "}
+                <span>{msg.event}</span> {JSON.stringify(msg.data)}
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 21 - 0
packages/studio/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "jsx": "react-jsx",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "paths": {
+      "@/*": ["./src/*"]
+    },
+    "baseUrl": "."
+  },
+  "include": ["src"]
+}

+ 22 - 0
packages/studio/vite.config.ts

@@ -0,0 +1,22 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from "@tailwindcss/vite";
+import { resolve } from "node:path";
+
+export default defineConfig({
+  plugins: [react(), tailwindcss()],
+  resolve: {
+    alias: {
+      "@": resolve(__dirname, "src"),
+    },
+  },
+  server: {
+    port: 4567,
+    proxy: {
+      "/api": {
+        target: `http://localhost:${process.env.INKOS_STUDIO_PORT ?? "4569"}`,
+        changeOrigin: true,
+      },
+    },
+  },
+});