Parcourir la source

wip(studio): preserve detached studio worktree changes

Ma il y a 7 mois
Parent
commit
c7ac2f2324

+ 71 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -0,0 +1,71 @@
+import { access, mkdir, rm } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import { mkdtemp } from "node:fs/promises";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { BookConfig } from "../models/book.js";
+
+const generateFoundationMock = vi.fn();
+const writeFoundationFilesMock = vi.fn();
+
+vi.mock("../agents/architect.js", () => ({
+  ArchitectAgent: class {
+    constructor(_ctx: unknown) {}
+
+    generateFoundation = generateFoundationMock;
+    writeFoundationFiles = writeFoundationFilesMock;
+  },
+}));
+
+vi.mock("../agents/rules-reader.js", () => ({
+  readGenreProfile: vi.fn(async () => ({
+    profile: {
+      numericalSystem: false,
+    },
+  })),
+}));
+
+describe("PipelineRunner.initBook", () => {
+  let projectRoot: string;
+
+  beforeEach(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-runner-"));
+    await mkdir(join(projectRoot, "books"), { recursive: true });
+    generateFoundationMock.mockReset();
+    writeFoundationFilesMock.mockReset();
+  });
+
+  afterEach(async () => {
+    await rm(projectRoot, { recursive: true, force: true });
+  });
+
+  it("does not leave a partial book directory behind when foundation generation fails", async () => {
+    generateFoundationMock.mockImplementationOnce(() => {
+      throw new Error("architect failed");
+    });
+
+    const { PipelineRunner } = await import("../pipeline/runner.js");
+    const runner = new PipelineRunner({
+      client: {} as never,
+      model: "test-model",
+      projectRoot,
+    });
+
+    const book: BookConfig = {
+      id: "broken-book",
+      title: "Broken Book",
+      genre: "xuanhuan",
+      platform: "qidian",
+      status: "outlining",
+      targetChapters: 10,
+      chapterWordCount: 3000,
+      language: "zh",
+      createdAt: new Date().toISOString(),
+      updatedAt: new Date().toISOString(),
+    };
+
+    await expect(runner.initBook(book)).rejects.toThrow("architect failed");
+    expect(generateFoundationMock).toHaveBeenCalledOnce();
+    await expect(access(join(projectRoot, "books", book.id))).rejects.toThrow();
+  });
+});

+ 67 - 10
packages/core/src/pipeline/runner.ts

@@ -21,7 +21,7 @@ import type { WebhookEvent } from "../notify/webhook.js";
 import type { AgentContext } from "../agents/base.js";
 import type { AuditResult, AuditIssue } from "../agents/continuity.js";
 import type { RadarResult } from "../agents/radar.js";
-import { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
+import { access, readFile, readdir, writeFile, mkdir, rename, rm } from "node:fs/promises";
 import { join } from "node:path";
 
 export interface PipelineConfig {
@@ -187,19 +187,30 @@ export class PipelineRunner {
   async initBook(book: BookConfig): Promise<void> {
     const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
     const bookDir = this.state.bookDir(book.id);
+    const stagingDir = join(
+      this.state.booksDir,
+      `.tmp-${book.id}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+    );
 
-    await this.state.saveBookConfig(book.id, book);
+    await this.ensureBookTargetIsWritable(book.id);
+    await rm(stagingDir, { recursive: true, force: true });
 
-    const { profile: gp } = await this.loadGenreProfile(book.genre);
-    const foundation = await architect.generateFoundation(book, this.config.externalContext);
-    await architect.writeFoundationFiles(bookDir, foundation, gp.numericalSystem);
+    try {
+      await this.writeBookConfigToDir(stagingDir, book);
 
-    // Ensure chapters directory exists (prevents ENOENT if init was previously interrupted)
-    await mkdir(join(bookDir, "chapters"), { recursive: true });
-    await this.state.saveChapterIndex(book.id, []);
+      const { profile: gp } = await this.loadGenreProfile(book.genre);
+      const foundation = await architect.generateFoundation(book, this.config.externalContext);
+      await architect.writeFoundationFiles(stagingDir, foundation, gp.numericalSystem);
 
-    // Snapshot initial state so rewrite of chapter 1 can restore to pre-chapter state
-    await this.state.snapshotState(book.id, 0);
+      await mkdir(join(stagingDir, "chapters"), { recursive: true });
+      await writeFile(join(stagingDir, "chapters", "index.json"), "[]\n", "utf-8");
+      await this.snapshotStateAtDir(stagingDir, 0);
+
+      await rename(stagingDir, bookDir);
+    } catch (e) {
+      await rm(stagingDir, { recursive: true, force: true });
+      throw e;
+    }
   }
 
   /** Import external source material and generate fanfic_canon.md */
@@ -247,6 +258,52 @@ export class PipelineRunner {
     await this.state.snapshotState(book.id, 0);
   }
 
+  private async ensureBookTargetIsWritable(bookId: string): Promise<void> {
+    const bookDir = this.state.bookDir(bookId);
+    try {
+      await access(bookDir);
+    } catch {
+      return;
+    }
+
+    try {
+      await access(join(bookDir, "story", "story_bible.md"));
+      throw new Error(`Book "${bookId}" already exists at books/${bookId}/`);
+    } catch (e) {
+      if (e instanceof Error && e.message.includes("already exists")) {
+        throw e;
+      }
+      await rm(bookDir, { recursive: true, force: true });
+    }
+  }
+
+  private async writeBookConfigToDir(bookDir: string, book: BookConfig): Promise<void> {
+    await mkdir(bookDir, { recursive: true });
+    await writeFile(join(bookDir, "book.json"), JSON.stringify(book, null, 2), "utf-8");
+  }
+
+  private async snapshotStateAtDir(bookDir: string, chapterNumber: number): Promise<void> {
+    const storyDir = join(bookDir, "story");
+    const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
+    await mkdir(snapshotDir, { recursive: true });
+
+    const files = [
+      "current_state.md", "particle_ledger.md", "pending_hooks.md",
+      "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
+    ];
+
+    await Promise.all(
+      files.map(async (f) => {
+        try {
+          const content = await readFile(join(storyDir, f), "utf-8");
+          await writeFile(join(snapshotDir, f), content, "utf-8");
+        } catch {
+          // file doesn't exist yet
+        }
+      }),
+    );
+  }
+
   /** Write a single draft chapter. Saves chapter file + truth files + index + snapshot. */
   async writeDraft(bookId: string, context?: string, wordCount?: number): Promise<DraftResult> {
     const releaseLock = await this.state.acquireBookLock(bookId);

+ 1 - 0
packages/studio/index.html

@@ -3,6 +3,7 @@
   <head>
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📜</text></svg>">
     <title>InkOS Studio</title>
   </head>
   <body>

+ 5 - 3
packages/studio/package.json

@@ -5,10 +5,11 @@
   "description": "InkOS Studio — Web workbench for novel writing",
   "type": "module",
   "scripts": {
-    "dev": "vite",
+    "dev": "vite --host --port 4567",
     "build": "vite build",
     "preview": "vite preview",
-    "typecheck": "tsc --noEmit"
+    "typecheck": "tsc --noEmit",
+    "test": "vitest run"
   },
   "dependencies": {
     "@actalk/inkos-core": "workspace:*",
@@ -34,6 +35,7 @@
     "autoprefixer": "^10.4.0",
     "tailwindcss": "^4.0.0",
     "typescript": "^5.8.0",
-    "vite": "^6.0.0"
+    "vite": "^6.0.0",
+    "vitest": "^3.0.0"
   }
 }

+ 82 - 31
packages/studio/src/App.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect } from "react";
 import { Sidebar } from "./components/Sidebar";
-import { ChatBar } from "./components/ChatBar";
+import { ChatPanel } from "./components/ChatBar";
 import { Dashboard } from "./pages/Dashboard";
 import { BookDetail } from "./pages/BookDetail";
 import { BookCreate } from "./pages/BookCreate";
@@ -11,11 +11,16 @@ import { TruthFiles } from "./pages/TruthFiles";
 import { DaemonControl } from "./pages/DaemonControl";
 import { LogViewer } from "./pages/LogViewer";
 import { GenreManager } from "./pages/GenreManager";
+import { StyleManager } from "./pages/StyleManager";
+import { ImportManager } from "./pages/ImportManager";
+import { RadarView } from "./pages/RadarView";
+import { DoctorView } from "./pages/DoctorView";
 import { LanguageSelector } from "./pages/LanguageSelector";
 import { useSSE } from "./hooks/use-sse";
 import { useTheme } from "./hooks/use-theme";
 import { useI18n } from "./hooks/use-i18n";
 import { useApi } from "./hooks/use-api";
+import { Sun, Moon, Bell, MessageSquare } from "lucide-react";
 
 type Route =
   | { page: "dashboard" }
@@ -27,7 +32,11 @@ type Route =
   | { page: "truth"; bookId: string }
   | { page: "daemon" }
   | { page: "logs" }
-  | { page: "genres" };
+  | { page: "genres" }
+  | { page: "style" }
+  | { page: "import" }
+  | { page: "radar" }
+  | { page: "doctor" };
 
 export function App() {
   const [route, setRoute] = useState<Route>({ page: "dashboard" });
@@ -37,6 +46,7 @@ export function App() {
   const { data: project, refetch: refetchProject } = useApi<{ language: string; languageExplicit: boolean }>("/project");
   const [showLanguageSelector, setShowLanguageSelector] = useState(false);
   const [ready, setReady] = useState(false);
+  const [chatOpen, setChatOpen] = useState(false);
 
   const isDark = theme === "dark";
 
@@ -44,7 +54,6 @@ export function App() {
     document.documentElement.classList.toggle("dark", isDark);
   }, [isDark]);
 
-  // Check if language needs to be set (first-time flow)
   useEffect(() => {
     if (project) {
       if (!project.languageExplicit) {
@@ -66,6 +75,10 @@ export function App() {
     toDaemon: () => setRoute({ page: "daemon" }),
     toLogs: () => setRoute({ page: "logs" }),
     toGenres: () => setRoute({ page: "genres" }),
+    toStyle: () => setRoute({ page: "style" }),
+    toImport: () => setRoute({ page: "import" }),
+    toRadar: () => setRoute({ page: "radar" }),
+    toDoctor: () => setRoute({ page: "doctor" }),
   };
 
   const activePage =
@@ -74,7 +87,11 @@ export function App() {
       : route.page;
 
   if (!ready) {
-    return <div className="min-h-screen bg-background" />;
+    return (
+      <div className="min-h-screen bg-background flex items-center justify-center">
+        <div className="w-12 h-12 border-4 border-primary/20 border-t-primary rounded-full animate-spin" />
+      </div>
+    );
   }
 
   if (showLanguageSelector) {
@@ -94,30 +111,55 @@ export function App() {
   }
 
   return (
-    <div className="h-screen bg-background text-foreground flex overflow-hidden">
-      <Sidebar nav={nav} activePage={activePage} t={t} />
-
-      <div className="flex-1 flex flex-col min-w-0">
-        {/* Thin utility strip — pushed inward */}
-        <div className="h-12 shrink-0 flex items-center justify-end px-8 gap-4">
-          <button
-            onClick={() => setTheme(isDark ? "light" : "dark")}
-            className="w-8 h-8 flex items-center justify-center rounded-full bg-secondary text-muted-foreground hover:text-foreground hover:bg-secondary/80 transition-all text-sm"
-          >
-            {isDark ? "☀" : "☽"}
-          </button>
-          {sse.connected && (
-            <span className="text-xs text-muted-foreground uppercase tracking-wide">
-              {t("nav.connected")}
-            </span>
-          )}
-        </div>
-
-        {/* Scrollable main — centered with generous inset */}
-        <div className="flex-1 overflow-y-auto">
-          <div className="max-w-3xl mx-auto px-10 py-12">
+    <div className="h-screen bg-background text-foreground flex overflow-hidden font-sans">
+      {/* Left Sidebar */}
+      <Sidebar nav={nav} activePage={activePage} sse={sse} t={t} />
+
+      {/* Center Content */}
+      <div className="flex-1 flex flex-col min-w-0 bg-background/30 backdrop-blur-sm">
+        {/* Header Strip */}
+        <header className="h-14 shrink-0 flex items-center justify-between px-8 border-b border-border/40">
+          <div className="flex items-center gap-2">
+             <span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold">
+               InkOS Studio
+             </span>
+          </div>
+
+          <div className="flex items-center gap-3">
+
+            <button
+              onClick={() => setTheme(isDark ? "light" : "dark")}
+              className="w-8 h-8 flex items-center justify-center rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm"
+              title={isDark ? "Switch to Light Mode" : "Switch to Dark Mode"}
+            >
+              {isDark ? <Sun size={16} /> : <Moon size={16} />}
+            </button>
+
+            <button className="w-8 h-8 flex items-center justify-center rounded-lg bg-secondary text-muted-foreground hover:text-foreground transition-all relative">
+              <Bell size={16} />
+              <span className="absolute top-1 right-1 w-2 h-2 bg-primary rounded-full border-2 border-background" />
+            </button>
+
+            {/* Chat Panel Toggle */}
+            <button
+              onClick={() => setChatOpen((prev) => !prev)}
+              className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all shadow-sm ${
+                chatOpen
+                  ? "bg-primary text-primary-foreground shadow-primary/20"
+                  : "bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10"
+              }`}
+              title="Toggle AI Assistant"
+            >
+              <MessageSquare size={16} />
+            </button>
+          </div>
+        </header>
+
+        {/* Main Content Area */}
+        <main className="flex-1 overflow-y-auto scroll-smooth">
+          <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
             {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" && <BookDetail bookId={route.bookId} nav={nav} theme={theme} t={t} sse={sse} />}
             {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} />}
@@ -126,12 +168,21 @@ export function App() {
             {route.page === "daemon" && <DaemonControl nav={nav} theme={theme} t={t} sse={sse} />}
             {route.page === "logs" && <LogViewer nav={nav} theme={theme} t={t} />}
             {route.page === "genres" && <GenreManager nav={nav} theme={theme} t={t} />}
+            {route.page === "style" && <StyleManager nav={nav} theme={theme} t={t} />}
+            {route.page === "import" && <ImportManager nav={nav} theme={theme} t={t} />}
+            {route.page === "radar" && <RadarView nav={nav} theme={theme} t={t} />}
+            {route.page === "doctor" && <DoctorView nav={nav} theme={theme} t={t} />}
           </div>
-        </div>
-
-        {/* Chat bar — inset to match content width */}
-        <ChatBar t={t} sse={sse} />
+        </main>
       </div>
+
+      {/* Right Chat Panel */}
+      <ChatPanel
+        open={chatOpen}
+        onClose={() => setChatOpen(false)}
+        t={t}
+        sse={sse}
+      />
     </div>
   );
 }

+ 52 - 2
packages/studio/src/api/book-create.test.ts

@@ -1,5 +1,5 @@
-import { describe, expect, it } from "vitest";
-import { buildStudioBookConfig, normalizeStudioPlatform } from "./book-create";
+import { describe, expect, it, vi } from "vitest";
+import { buildStudioBookConfig, normalizeStudioPlatform, waitForStudioBookReady } from "./book-create";
 
 describe("normalizeStudioPlatform", () => {
   it("keeps supported chinese platform ids and folds unsupported values to other", () => {
@@ -51,3 +51,53 @@ describe("buildStudioBookConfig", () => {
     expect(config.id).toBe("english-book");
   });
 });
+
+describe("waitForStudioBookReady", () => {
+  it("retries until the created book becomes readable", async () => {
+    const fetchImpl = vi
+      .fn<typeof fetch>()
+      .mockResolvedValueOnce(new Response(JSON.stringify({ error: "Book not found" }), {
+        status: 404,
+        headers: { "Content-Type": "application/json" },
+      }))
+      .mockResolvedValueOnce(new Response(JSON.stringify({
+        book: { id: "new-book" },
+        chapters: [],
+        nextChapter: 1,
+      }), {
+        status: 200,
+        headers: { "Content-Type": "application/json" },
+      }));
+    const wait = vi.fn(async () => {});
+
+    const result = await waitForStudioBookReady("new-book", {
+      fetchImpl,
+      wait,
+      maxAttempts: 2,
+      retryDelayMs: 1,
+    });
+
+    expect(fetchImpl).toHaveBeenCalledTimes(2);
+    expect(wait).toHaveBeenCalledTimes(1);
+    expect(result).toMatchObject({
+      book: { id: "new-book" },
+      nextChapter: 1,
+    });
+  });
+
+  it("throws a clear error when the book never becomes readable", async () => {
+    const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
+      new Response(JSON.stringify({ error: "Book not found" }), {
+        status: 404,
+        headers: { "Content-Type": "application/json" },
+      }),
+    );
+
+    await expect(waitForStudioBookReady("missing-book", {
+      fetchImpl,
+      wait: async () => {},
+      maxAttempts: 2,
+      retryDelayMs: 1,
+    })).rejects.toThrow('Book "missing-book" was not ready after 2 attempts.');
+  });
+});

+ 20 - 0
packages/studio/src/api/errors.ts

@@ -0,0 +1,20 @@
+/**
+ * Structured API error handling.
+ * Ported from PR #96 (Te9ui1a) — typed error codes for consistent JSON responses.
+ */
+
+export class ApiError extends Error {
+  readonly status: number;
+  readonly code: string;
+
+  constructor(status: number, code: string, message: string) {
+    super(message);
+    this.name = "ApiError";
+    this.status = status;
+    this.code = code;
+  }
+}
+
+export function isMissingFileError(error: unknown): boolean {
+  return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
+}

+ 177 - 0
packages/studio/src/api/lib/run-store.ts

@@ -0,0 +1,177 @@
+/**
+ * In-memory event store for run lifecycle tracking.
+ * Ported from PR #96 (Te9ui1a) — immutable updates, pub/sub per run.
+ */
+
+import { randomUUID } from "node:crypto";
+import type {
+  RunAction,
+  RunLogEntry,
+  RunStatus,
+  RunStreamEvent,
+  StudioRun,
+} from "../../shared/contracts.js";
+
+type RunSubscriber = (event: RunStreamEvent) => void;
+
+export class RunStore {
+  private readonly runs = new Map<string, StudioRun>();
+  private readonly subscribers = new Map<string, Set<RunSubscriber>>();
+
+  create(input: {
+    bookId: string;
+    chapterNumber?: number;
+    action: RunAction;
+  }): StudioRun {
+    const now = new Date().toISOString();
+    const run: StudioRun = {
+      id: randomUUID(),
+      bookId: input.bookId,
+      chapter: input.chapterNumber ?? null,
+      chapterNumber: input.chapterNumber ?? null,
+      action: input.action,
+      status: "queued",
+      stage: "Queued",
+      createdAt: now,
+      updatedAt: now,
+      startedAt: null,
+      finishedAt: null,
+      logs: [],
+    };
+
+    this.runs.set(run.id, run);
+    this.publish(run.id, { type: "snapshot", runId: run.id, run });
+    return run;
+  }
+
+  list(): ReadonlyArray<StudioRun> {
+    return [...this.runs.values()].sort((a, b) =>
+      b.createdAt.localeCompare(a.createdAt),
+    );
+  }
+
+  get(runId: string): StudioRun | null {
+    return this.runs.get(runId) ?? null;
+  }
+
+  findActiveRun(bookId: string): StudioRun | null {
+    for (const run of this.runs.values()) {
+      if (
+        run.bookId === bookId &&
+        (run.status === "queued" || run.status === "running")
+      ) {
+        return run;
+      }
+    }
+    return null;
+  }
+
+  markRunning(runId: string, stage: string): StudioRun {
+    return this.update(
+      runId,
+      { status: "running", stage, startedAt: new Date().toISOString() },
+      [
+        { type: "status", runId, status: "running" },
+        { type: "stage", runId, stage },
+      ],
+    );
+  }
+
+  updateStage(runId: string, stage: string): StudioRun {
+    return this.update(runId, { stage }, [{ type: "stage", runId, stage }]);
+  }
+
+  appendLog(runId: string, log: RunLogEntry): StudioRun {
+    return this.update(runId, (run) => ({ logs: [...run.logs, log] }), [
+      { type: "log", runId, log },
+    ]);
+  }
+
+  succeed(runId: string, result: unknown): StudioRun {
+    return this.update(
+      runId,
+      {
+        status: "succeeded",
+        stage: "Completed",
+        finishedAt: new Date().toISOString(),
+        result,
+        error: undefined,
+      },
+      [{ type: "status", runId, status: "succeeded", result }],
+      true,
+    );
+  }
+
+  fail(runId: string, error: string): StudioRun {
+    return this.update(
+      runId,
+      {
+        status: "failed",
+        stage: "Failed",
+        finishedAt: new Date().toISOString(),
+        error,
+      },
+      [{ type: "status", runId, status: "failed", error }],
+      true,
+    );
+  }
+
+  subscribe(runId: string, subscriber: RunSubscriber): () => void {
+    const current =
+      this.subscribers.get(runId) ?? new Set<RunSubscriber>();
+    current.add(subscriber);
+    this.subscribers.set(runId, current);
+
+    return () => {
+      const listeners = this.subscribers.get(runId);
+      if (!listeners) return;
+      listeners.delete(subscriber);
+      if (listeners.size === 0) this.subscribers.delete(runId);
+    };
+  }
+
+  private update(
+    runId: string,
+    patch: Partial<StudioRun> | ((run: StudioRun) => Partial<StudioRun>),
+    events: ReadonlyArray<RunStreamEvent>,
+    publishSnapshot = false,
+  ): StudioRun {
+    const current = this.runs.get(runId);
+    if (!current) throw new Error(`Run ${runId} not found.`);
+
+    const partial = typeof patch === "function" ? patch(current) : patch;
+    const next: StudioRun = {
+      ...current,
+      ...partial,
+      updatedAt: new Date().toISOString(),
+    };
+    this.runs.set(runId, next);
+
+    for (const event of events) {
+      this.publish(runId, event);
+    }
+    if (publishSnapshot) {
+      this.publish(runId, { type: "snapshot", runId, run: next });
+    }
+
+    return next;
+  }
+
+  private publish(runId: string, event: RunStreamEvent): void {
+    const listeners = this.subscribers.get(runId);
+    if (!listeners || listeners.size === 0) return;
+
+    const payload =
+      event.type === "snapshot"
+        ? { ...event, run: event.run ?? this.get(runId) ?? undefined }
+        : event;
+
+    for (const listener of listeners) {
+      listener(payload as RunStreamEvent);
+    }
+  }
+}
+
+export function isTerminalRunStatus(status: RunStatus): boolean {
+  return status === "succeeded" || status === "failed";
+}

+ 50 - 0
packages/studio/src/api/lib/sse.ts

@@ -0,0 +1,50 @@
+/**
+ * SSE stream factory for run event streaming.
+ * Ported from PR #96 (Te9ui1a) — typed ReadableStream with auto-close.
+ */
+
+import type { RunStreamEvent } from "../../shared/contracts.js";
+
+function encodeSse(event: RunStreamEvent): Uint8Array {
+  return new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`);
+}
+
+export function createRunEventStream(
+  initialEvent: RunStreamEvent,
+  subscribe: (send: (event: RunStreamEvent) => void) => () => void,
+  shouldClose: (event: RunStreamEvent) => boolean,
+): Response {
+  let unsubscribe: (() => void) | null = null;
+
+  const stream = new ReadableStream<Uint8Array>({
+    start(controller) {
+      controller.enqueue(encodeSse(initialEvent));
+
+      if (shouldClose(initialEvent)) {
+        controller.close();
+        return;
+      }
+
+      unsubscribe = subscribe((event) => {
+        controller.enqueue(encodeSse(event));
+        if (shouldClose(event)) {
+          unsubscribe?.();
+          unsubscribe = null;
+          controller.close();
+        }
+      });
+    },
+    cancel() {
+      unsubscribe?.();
+      unsubscribe = null;
+    },
+  });
+
+  return new Response(stream, {
+    headers: {
+      "content-type": "text/event-stream; charset=utf-8",
+      "cache-control": "no-cache",
+      connection: "keep-alive",
+    },
+  });
+}

+ 76 - 0
packages/studio/src/api/safety.ts

@@ -0,0 +1,76 @@
+/**
+ * Security validation utilities for Studio API.
+ * Ported from PR #97 (PapainTea) — ReDoS prevention, path traversal, port parsing.
+ */
+
+const SAFE_UPLOAD_FILE_ID = /^[A-Za-z0-9-]{1,64}$/;
+const MAX_IMPORT_PATTERN_LENGTH = 160;
+const SAFE_IMPORT_PATTERN = /^[\p{L}\p{N}\s[\]\\.^$*+?{}\-,。::、_]+$/u;
+
+/** Validates fileId against whitelist to prevent path traversal. */
+export function isSafeUploadFileId(fileId: string): boolean {
+  return typeof fileId === "string" && SAFE_UPLOAD_FILE_ID.test(fileId);
+}
+
+/** Validates bookId — blocks traversal sequences and null bytes. */
+export function isSafeBookId(bookId: string): boolean {
+  return bookId.length > 0 && !bookId.includes("..") && !/[\\/\0]/.test(bookId);
+}
+
+/**
+ * Builds a regex from user-provided pattern with ReDoS prevention.
+ * Blocks grouping, alternation, backreferences, and lookahead/behind.
+ */
+export function buildImportRegex(pattern: string): RegExp {
+  const normalized = String(pattern ?? "").trim();
+  if (!normalized) {
+    throw new Error("Import pattern is required");
+  }
+  if (normalized.length > MAX_IMPORT_PATTERN_LENGTH) {
+    throw new Error(`Import pattern too long (max ${MAX_IMPORT_PATTERN_LENGTH} chars)`);
+  }
+  if (/[()|]/.test(normalized) || /\\[1-9]/.test(normalized) || normalized.includes("(?")) {
+    throw new Error("Import pattern uses unsafe regex features");
+  }
+  if (!SAFE_IMPORT_PATTERN.test(normalized)) {
+    throw new Error("Import pattern is invalid");
+  }
+
+  try {
+    return new RegExp(normalized, "g");
+  } catch {
+    throw new Error("Import pattern is invalid");
+  }
+}
+
+/** Strips filesystem paths from upload responses — only returns relative/safe fields. */
+export function createUploadResponse(input: {
+  readonly fileId: string;
+  readonly size: number;
+  readonly chapterCount: number;
+  readonly firstTitle: string;
+  readonly totalChars: number;
+}): {
+  readonly ok: true;
+  readonly fileId: string;
+  readonly size: number;
+  readonly chapterCount: number;
+  readonly firstTitle: string;
+  readonly totalChars: number;
+} {
+  return {
+    ok: true,
+    fileId: input.fileId,
+    size: input.size,
+    chapterCount: input.chapterCount,
+    firstTitle: input.firstTitle,
+    totalChars: input.totalChars,
+  };
+}
+
+/** Parses port from environment with safe fallback. */
+export function resolveServerPort(env: Record<string, string | undefined> = process.env): number {
+  const raw = env.INKOS_STUDIO_PORT ?? env.PORT ?? "4567";
+  const parsed = Number.parseInt(raw, 10);
+  return Number.isFinite(parsed) && parsed > 0 ? parsed : 4567;
+}

+ 32 - 1
packages/studio/src/api/server.test.ts

@@ -1,9 +1,10 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { mkdtemp, rm } from "node:fs/promises";
+import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
 import { join } from "node:path";
 import { tmpdir } from "node:os";
 
 const schedulerStartMock = vi.fn<() => Promise<void>>();
+const initBookMock = vi.fn();
 
 const logger = {
   child: () => logger,
@@ -39,6 +40,8 @@ vi.mock("@actalk/inkos-core", () => {
 
   class MockPipelineRunner {
     constructor(_config: unknown) {}
+
+    initBook = initBookMock;
   }
 
   class MockScheduler {
@@ -104,6 +107,7 @@ describe("createStudioServer daemon lifecycle", () => {
   beforeEach(async () => {
     root = await mkdtemp(join(tmpdir(), "inkos-studio-server-"));
     schedulerStartMock.mockReset();
+    initBookMock.mockReset();
   });
 
   afterEach(async () => {
@@ -138,4 +142,31 @@ describe("createStudioServer daemon lifecycle", () => {
 
     resolveStart?.();
   });
+
+  it("rejects create requests when a complete book with the same id already exists", async () => {
+    await mkdir(join(root, "books", "existing-book", "story"), { recursive: true });
+    await writeFile(join(root, "books", "existing-book", "book.json"), JSON.stringify({ id: "existing-book" }), "utf-8");
+    await writeFile(join(root, "books", "existing-book", "story", "story_bible.md"), "# existing", "utf-8");
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(projectConfig as never, root);
+
+    const response = await app.request("http://localhost/api/books/create", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        title: "Existing Book",
+        genre: "xuanhuan",
+        platform: "qidian",
+        language: "zh",
+      }),
+    });
+
+    expect(response.status).toBe(409);
+    await expect(response.json()).resolves.toMatchObject({
+      error: expect.stringContaining('Book "existing-book" already exists'),
+    });
+    expect(initBookMock).not.toHaveBeenCalled();
+    await expect(access(join(root, "books", "existing-book", "story", "story_bible.md"))).resolves.toBeUndefined();
+  });
 });

+ 342 - 109
packages/studio/src/components/ChatBar.tsx

@@ -1,6 +1,39 @@
-import { useState, useRef, useEffect } from "react";
+import { useState, useRef, useEffect, useMemo } from "react";
 import type { TFunction } from "../hooks/use-i18n";
 import type { SSEMessage } from "../hooks/use-sse";
+import { cn } from "../lib/utils";
+import { fetchJson, postApi } from "../hooks/use-api";
+import {
+  // Panel controls
+  Sparkles,
+  Trash2,
+  PanelRightClose,
+  ArrowUp,
+  Loader2,
+  MessageSquare,
+  Lightbulb,
+  // Message avatars
+  User,
+  CheckCircle2,
+  XCircle,
+  BotMessageSquare,
+  // Message content badges
+  BadgeCheck,
+  CircleAlert,
+  // Status phase icons
+  Brain,
+  PenTool,
+  Shield,
+  Wrench,
+  AlertTriangle,
+  // Quick command chips
+  Zap,
+  Search,
+  FileOutput,
+  TrendingUp,
+} from "lucide-react";
+
+// ── Types ──
 
 interface ChatMessage {
   readonly role: "user" | "assistant";
@@ -8,12 +41,144 @@ interface ChatMessage {
   readonly timestamp: number;
 }
 
-export function ChatBar({ t, sse }: {
-  t: TFunction;
-  sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
+// ── Sub-components ──
+
+function StatusIcon({ phase }: { readonly phase: string }) {
+  const lower = phase.toLowerCase();
+
+  if (lower.includes("think") || lower.includes("plan"))
+    return <Brain size={14} className="text-purple-500 animate-pulse" />;
+  if (lower.includes("writ") || lower.includes("draft") || lower.includes("stream"))
+    return <PenTool size={14} className="text-blue-500 chat-icon-write" />;
+  if (lower.includes("audit") || lower.includes("review"))
+    return <Shield size={14} className="text-amber-500 animate-pulse" />;
+  if (lower.includes("revis") || lower.includes("fix") || lower.includes("spot"))
+    return <Wrench size={14} className="text-orange-500 chat-icon-spin-slow" />;
+  if (lower.includes("complet") || lower.includes("done") || lower.includes("success"))
+    return <CheckCircle2 size={14} className="text-emerald-500 chat-icon-pop" />;
+  if (lower.includes("error") || lower.includes("fail"))
+    return <AlertTriangle size={14} className="text-destructive animate-pulse" />;
+  return <Loader2 size={14} className="text-primary animate-spin" />;
+}
+
+function EmptyState() {
+  return (
+    <div className="h-full flex flex-col items-center justify-center text-center opacity-40 select-none fade-in">
+      <div className="w-14 h-14 rounded-2xl border border-dashed border-border flex items-center justify-center mb-4 bg-secondary/30">
+        <BotMessageSquare size={24} className="text-muted-foreground" />
+      </div>
+      <p className="text-sm italic font-serif mb-1">How shall we proceed today?</p>
+      <p className="text-[10px] text-muted-foreground/60 uppercase tracking-widest">Type a command below</p>
+    </div>
+  );
+}
+
+function ThinkingBubble() {
+  return (
+    <div className="flex gap-2.5 chat-msg-assistant">
+      <div className="w-7 h-7 rounded-lg bg-secondary flex items-center justify-center shrink-0 chat-thinking-glow">
+        <Brain size={14} className="text-primary animate-pulse" />
+      </div>
+      <div className="bg-card border border-border/50 px-3.5 py-2.5 rounded-2xl rounded-tl-sm flex gap-1.5 items-center">
+        <span className="w-1.5 h-1.5 bg-primary/50 rounded-full chat-typing-dot" />
+        <span className="w-1.5 h-1.5 bg-primary/50 rounded-full chat-typing-dot" />
+        <span className="w-1.5 h-1.5 bg-primary/50 rounded-full chat-typing-dot" />
+      </div>
+    </div>
+  );
+}
+
+function MessageBubble({ msg }: { readonly msg: ChatMessage }) {
+  const isUser = msg.role === "user";
+  const isStatus = msg.content.startsWith("⋯");
+  const isSuccess = msg.content.startsWith("✓");
+  const isError = msg.content.startsWith("✗");
+
+  return (
+    <div className={cn(
+      "flex gap-2.5",
+      isUser ? "flex-row-reverse chat-msg-user" : "chat-msg-assistant",
+    )}>
+      {/* Avatar */}
+      <div className={cn(
+        "w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 transition-colors",
+        isUser ? "bg-primary/10" : "bg-secondary",
+      )}>
+        {isUser ? (
+          <User size={14} className="text-primary" />
+        ) : isSuccess ? (
+          <CheckCircle2 size={14} className="text-emerald-500 chat-icon-pop" />
+        ) : isError ? (
+          <XCircle size={14} className="text-destructive" />
+        ) : isStatus ? (
+          <Loader2 size={14} className="text-primary animate-spin" />
+        ) : (
+          <Sparkles size={14} className="text-primary" />
+        )}
+      </div>
+
+      {/* Bubble */}
+      <div className={cn(
+        "max-w-[80%] rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm",
+        isUser
+          ? "bg-primary text-primary-foreground font-medium rounded-tr-sm"
+          : isStatus
+            ? "bg-secondary/50 border border-border/30 text-muted-foreground font-mono text-xs rounded-tl-sm"
+            : "bg-card border border-border/50 text-foreground font-serif rounded-tl-sm",
+      )}>
+        {/* Status badge */}
+        {isSuccess && (
+          <span className="flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400 font-sans font-medium text-[10px] mb-1 uppercase tracking-wider">
+            <BadgeCheck size={11} />
+            Complete
+          </span>
+        )}
+        {isError && (
+          <span className="flex items-center gap-1.5 text-destructive font-sans font-medium text-[10px] mb-1 uppercase tracking-wider">
+            <CircleAlert size={11} />
+            Error
+          </span>
+        )}
+
+        <div>{msg.content}</div>
+
+        {/* Timestamp */}
+        <div className={cn(
+          "text-[9px] mt-1.5 font-mono",
+          isUser ? "text-primary-foreground/40" : "text-muted-foreground/40",
+        )}>
+          {new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function QuickChip({ icon, label, onClick }: {
+  readonly icon: React.ReactNode;
+  readonly label: string;
+  readonly onClick: () => void;
+}) {
+  return (
+    <button
+      onClick={onClick}
+      className="shrink-0 flex items-center gap-1 px-2.5 py-1 rounded-lg bg-secondary/50 border border-border/30 text-[10px] font-medium text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/5 transition-all group"
+    >
+      <span className="group-hover:scale-110 transition-transform">{icon}</span>
+      {label}
+    </button>
+  );
+}
+
+// ── Main Component ──
+
+export function ChatPanel({ open, onClose, t, sse }: {
+  readonly open: boolean;
+  readonly onClose: () => void;
+  readonly t: TFunction;
+  readonly sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
 }) {
   const [input, setInput] = useState("");
-  const [expanded, setExpanded] = useState(false);
   const [messages, setMessages] = useState<ReadonlyArray<ChatMessage>>([]);
   const [loading, setLoading] = useState(false);
   const inputRef = useRef<HTMLInputElement>(null);
@@ -22,10 +187,17 @@ export function ChatBar({ t, sse }: {
   // Auto-scroll on new messages
   useEffect(() => {
     if (scrollRef.current) {
-      scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+      scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
     }
   }, [messages, sse.messages.length]);
 
+  // Focus input when panel opens
+  useEffect(() => {
+    if (open) {
+      setTimeout(() => inputRef.current?.focus(), 300);
+    }
+  }, [open]);
+
   // SSE events → assistant messages
   useEffect(() => {
     const recent = sse.messages.slice(-1)[0];
@@ -52,9 +224,8 @@ export function ChatBar({ t, sse }: {
     }
     if (recent.event === "log" && loading) {
       const msg = d.message as string;
-      if (msg && (msg.includes("Phase") || msg.includes("streaming"))) {
+      if (msg && (msg.includes("Phase") || msg.includes("streaming") || msg.includes("Writing") || msg.includes("Audit") || msg.includes("Revis"))) {
         setMessages((prev) => {
-          // Replace the last "thinking" message instead of appending
           const last = prev[prev.length - 1];
           if (last?.role === "assistant" && last.content.startsWith("⋯")) {
             return [...prev.slice(0, -1), { role: "assistant", content: `⋯ ${msg}`, timestamp: Date.now() }];
@@ -65,162 +236,224 @@ export function ChatBar({ t, sse }: {
     }
   }, [sse.messages.length]);
 
+  // Current phase for status bar
+  const currentPhase = useMemo(() => {
+    const lastStatus = [...messages].reverse().find((m) => m.role === "assistant" && m.content.startsWith("⋯"));
+    return lastStatus?.content.replace("⋯ ", "") ?? "Initializing...";
+  }, [messages]);
+
   const handleSubmit = async () => {
     const text = input.trim();
     if (!text || loading) return;
 
     setInput("");
-    setExpanded(true);
     setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]);
     setLoading(true);
 
-    // Simple command routing — check if it's a direct action
     const lower = text.toLowerCase();
 
     try {
       if (lower.match(/^(写下一章|write next)/)) {
-        // Extract book id from context or use first book
-        const res = await fetch("/api/books");
-        const { books } = await res.json() as { books: ReadonlyArray<{ id: string }> };
+        const { books } = await fetchJson<{ books: ReadonlyArray<{ id: string }> }>("/books");
         if (books.length > 0) {
-          setMessages((prev) => [...prev, { role: "assistant", content: "⋯ Starting...", timestamp: Date.now() }]);
-          await fetch(`/api/books/${books[0]!.id}/write-next`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
-          // SSE will handle the rest
+          setMessages((prev) => [...prev, { role: "assistant", content: "⋯ Initializing manuscript...", timestamp: Date.now() }]);
+          await postApi(`/books/${books[0]!.id}/write-next`, {});
           return;
         }
       }
 
-      // Fallback: send to agent API
-      const res = await fetch("/api/agent", {
+      const data = await fetchJson<{ response?: string; error?: string }>("/agent", {
         method: "POST",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify({ instruction: text }),
       });
-      const data = await res.json() as { response?: string; error?: string };
       setLoading(false);
       setMessages((prev) => [...prev, {
         role: "assistant",
-        content: data.response ?? data.error ?? "Done",
+        content: data.response ?? data.error ?? "Acknowledged.",
         timestamp: Date.now(),
       }]);
     } catch (e) {
       setLoading(false);
       setMessages((prev) => [...prev, {
         role: "assistant",
-        content: `Error: ${e instanceof Error ? e.message : String(e)}`,
+        content: `✗ ${e instanceof Error ? e.message : String(e)}`,
         timestamp: Date.now(),
       }]);
     }
   };
 
+  const handleQuickCommand = (command: string) => {
+    setInput(command);
+    setTimeout(() => {
+      handleSubmit();
+    }, 50);
+  };
+
   const isZh = t("nav.connected") === "已连接";
 
+  // Rotating tips
   const TIPS_ZH = [
-    "试试:写下一章",
-    "试试:审计第5章",
-    "试试:帮我创建一本都市修仙小说",
-    "试试:扫描市场趋势",
-    "试试:导出全书为 epub",
-    "试试:分析这篇文章的文风 → 导入到我的书",
-    "试试:导入已有章节续写",
-    "试试:创建一个玄幻题材的同人",
-    "试试:查看第3章的审计问题",
-    "试试:修订第5章,用 spot-fix 模式",
+    "写下一章", "审计第5章", "帮我创建一本都市修仙小说",
+    "扫描市场趋势", "导出全书为 epub", "分析文风 → 导入到我的书",
+    "导入已有章节续写", "创建一个玄幻题材的同人", "修订第5章,spot-fix",
   ];
-
   const TIPS_EN = [
-    "Try: write next chapter",
-    "Try: audit chapter 5",
-    "Try: create a LitRPG novel about a programmer",
-    "Try: scan market trends",
-    "Try: export book as epub",
-    "Try: analyze this text's style → import to my book",
-    "Try: import existing chapters to continue",
-    "Try: create a progression fantasy fanfic",
-    "Try: show audit issues for chapter 3",
-    "Try: revise chapter 5 with spot-fix mode",
+    "write next chapter", "audit chapter 5", "create a LitRPG novel",
+    "scan market trends", "export book as epub", "analyze style → import",
+    "import chapters to continue", "create a progression fantasy fanfic", "revise chapter 5, spot-fix",
   ];
-
   const tips = isZh ? TIPS_ZH : TIPS_EN;
   const [tipIndex, setTipIndex] = useState(() => Math.floor(Math.random() * tips.length));
 
-  // Rotate tips every 8 seconds when idle
   useEffect(() => {
-    if (input || expanded) return;
-    const timer = setInterval(() => {
-      setTipIndex((i) => (i + 1) % tips.length);
-    }, 8000);
+    if (input) return;
+    const timer = setInterval(() => setTipIndex((i) => (i + 1) % tips.length), 8000);
     return () => clearInterval(timer);
-  }, [input, expanded, tips.length]);
-
-  const chatPlaceholder = tips[tipIndex]!;
+  }, [input, tips.length]);
 
   return (
-    <div className="border border-border/60 bg-card/40 mx-6 mb-3 rounded-md">
-      {/* Expanded message area */}
-      {expanded && messages.length > 0 && (
-        <div>
+    <aside
+      className={cn(
+        "h-full flex flex-col border-l border-border/40 bg-background/80 backdrop-blur-md chat-panel-enter shrink-0 overflow-hidden",
+        open ? "w-[380px] opacity-100" : "w-0 opacity-0",
+      )}
+    >
+      {open && (
+        <>
+          {/* ── Section 1: Header ── */}
+          <div className="h-12 shrink-0 px-4 flex items-center justify-between border-b border-border/40">
+            <div className="flex items-center gap-2.5">
+              <div className="relative">
+                <Sparkles size={15} className="text-primary chat-icon-glow" />
+                {loading && (
+                  <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-primary rounded-full animate-ping" />
+                )}
+              </div>
+              <span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground">
+                InkOS Assistant
+              </span>
+            </div>
+            <div className="flex items-center gap-1">
+              <button
+                onClick={() => setMessages([])}
+                className="p-1.5 rounded-md text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors group"
+                title="Clear conversation"
+              >
+                <Trash2 size={14} className="group-hover:animate-[shake_0.3s_ease-in-out]" />
+              </button>
+              <button
+                onClick={onClose}
+                className="p-1.5 rounded-md text-muted-foreground hover:bg-secondary transition-colors group"
+                title="Close panel"
+              >
+                <PanelRightClose size={14} className="group-hover:translate-x-0.5 transition-transform" />
+              </button>
+            </div>
+          </div>
+
+          {/* ── Section 2: Status Bar (when loading) ── */}
+          {loading && (
+            <div className="shrink-0 px-4 py-2 border-b border-border/30 bg-primary/[0.03] fade-in">
+              <div className="flex items-center gap-2.5">
+                <StatusIcon phase={currentPhase} />
+                <span className="text-xs font-medium text-primary truncate flex-1">
+                  {currentPhase}
+                </span>
+                <div className="flex gap-1">
+                  <span className="w-1 h-1 bg-primary/40 rounded-full chat-typing-dot" />
+                  <span className="w-1 h-1 bg-primary/40 rounded-full chat-typing-dot" />
+                  <span className="w-1 h-1 bg-primary/40 rounded-full chat-typing-dot" />
+                </div>
+              </div>
+            </div>
+          )}
+
+          {/* ── Section 3: Messages ── */}
           <div
             ref={scrollRef}
-            className="max-h-[180px] overflow-y-auto px-4 py-3 space-y-2"
+            className="flex-1 overflow-y-auto px-4 py-4 space-y-3"
           >
-            {messages.map((msg, i) => (
-              <div
-                key={i}
-                className={`text-sm leading-relaxed ${
-                  msg.role === "user"
-                    ? "text-foreground"
-                    : msg.content.startsWith("✗")
-                      ? "text-destructive"
-                      : msg.content.startsWith("⋯")
-                        ? "text-muted-foreground"
-                        : "text-primary"
-                }`}
-              >
-                {msg.role === "user" && <span className="text-muted-foreground mr-2">›</span>}
-                {msg.content}
-              </div>
+            {messages.length === 0 && !loading && <EmptyState />}
+
+            {messages.map((msg) => (
+              <MessageBubble key={msg.timestamp} msg={msg} />
             ))}
+
             {loading && !messages.some((m) => m.content.startsWith("⋯")) && (
-              <div className="text-sm text-muted-foreground animate-pulse">⋯</div>
+              <ThinkingBubble />
             )}
           </div>
-        </div>
-      )}
 
-      {/* Input bar — centered to match main content */}
-      <div className="px-4 py-2 flex items-center gap-3">
-        <div className="flex-1 relative">
-          <input
-            ref={inputRef}
-            type="text"
-            value={input}
-            onChange={(e) => setInput(e.target.value)}
-            onFocus={() => messages.length > 0 && setExpanded(true)}
-            onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
-            placeholder={chatPlaceholder}
-            disabled={loading}
-            className="w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none disabled:opacity-50"
-          />
-        </div>
-        <div className="flex items-center gap-3 shrink-0">
-          {expanded && messages.length > 0 && (
-            <button
-              onClick={() => { setExpanded(false); setMessages([]); }}
-              className="text-xs text-muted-foreground hover:text-foreground transition-colors"
-            >
-              Clear
-            </button>
-          )}
-          <button
-            onClick={handleSubmit}
-            disabled={!input.trim() || loading}
-            className="w-7 h-7 rounded-md bg-primary/70 text-primary-foreground flex items-center justify-center text-xs hover:bg-primary transition-all disabled:opacity-15"
-          >
-            ↑
-          </button>
-        </div>
-      </div>
-    </div>
+          {/* ── Section 4: Quick Commands ── */}
+          <div className="shrink-0 px-3 py-2 border-t border-border/30 flex gap-1.5 overflow-x-auto">
+            <QuickChip
+              icon={<Zap size={11} />}
+              label={t("dash.writeNext")}
+              onClick={() => handleQuickCommand(isZh ? "写下一章" : "write next")}
+            />
+            <QuickChip
+              icon={<Search size={11} />}
+              label={t("book.audit")}
+              onClick={() => handleQuickCommand(isZh ? "审计第1章" : "audit chapter 1")}
+            />
+            <QuickChip
+              icon={<FileOutput size={11} />}
+              label={t("book.export")}
+              onClick={() => handleQuickCommand(isZh ? "导出全书" : "export book as epub")}
+            />
+            <QuickChip
+              icon={<TrendingUp size={11} />}
+              label={t("nav.radar")}
+              onClick={() => handleQuickCommand(isZh ? "扫描市场趋势" : "scan market trends")}
+            />
+          </div>
+
+          {/* ── Section 5: Input ── */}
+          <div className="shrink-0 p-3 border-t border-border/40">
+            <div className="flex items-center gap-2 rounded-xl bg-secondary/30 border border-border/40 px-3 py-1.5 focus-within:border-primary/40 focus-within:ring-2 focus-within:ring-primary/10 transition-all">
+              <MessageSquare size={14} className="text-muted-foreground/50 shrink-0" />
+              <input
+                ref={inputRef}
+                type="text"
+                value={input}
+                onChange={(e) => setInput(e.target.value)}
+                onKeyDown={(e) => {
+                  if (e.key === "Enter" && !e.shiftKey) {
+                    e.preventDefault();
+                    handleSubmit();
+                  }
+                }}
+                placeholder={t("common.enterCommand")}
+                disabled={loading}
+                className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground/50 outline-none ring-0 shadow-none disabled:opacity-50"
+                style={{ outline: "none", boxShadow: "none" }}
+              />
+              <button
+                onClick={handleSubmit}
+                disabled={!input.trim() || loading}
+                className="w-7 h-7 rounded-lg bg-primary text-primary-foreground flex items-center justify-center hover:scale-105 active:scale-95 transition-all disabled:opacity-20 disabled:scale-100 shadow-sm shadow-primary/20"
+              >
+                {loading ? (
+                  <Loader2 size={14} className="animate-spin" />
+                ) : (
+                  <ArrowUp size={14} strokeWidth={2.5} />
+                )}
+              </button>
+            </div>
+
+            {/* Rotating tip */}
+            {!input && (
+              <div className="mt-1.5 px-1 flex items-center gap-1.5">
+                <Lightbulb size={10} className="text-muted-foreground/30 shrink-0" />
+                <span className="text-[9px] text-muted-foreground/40 truncate fade-in" key={tipIndex}>
+                  {tips[tipIndex]}
+                </span>
+              </div>
+            )}
+          </div>
+        </>
+      )}
+    </aside>
   );
 }

+ 92 - 0
packages/studio/src/components/ConfirmDialog.tsx

@@ -0,0 +1,92 @@
+import { useEffect, useRef } from "react";
+import { AlertTriangle, X } from "lucide-react";
+
+interface ConfirmDialogProps {
+  readonly open: boolean;
+  readonly title: string;
+  readonly message: string;
+  readonly confirmLabel: string;
+  readonly cancelLabel: string;
+  readonly variant?: "danger" | "default";
+  readonly onConfirm: () => void;
+  readonly onCancel: () => void;
+}
+
+export function ConfirmDialog({
+  open,
+  title,
+  message,
+  confirmLabel,
+  cancelLabel,
+  variant = "default",
+  onConfirm,
+  onCancel,
+}: ConfirmDialogProps) {
+  const overlayRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!open) return;
+    const handleKey = (e: KeyboardEvent) => {
+      if (e.key === "Escape") onCancel();
+    };
+    document.addEventListener("keydown", handleKey);
+    return () => document.removeEventListener("keydown", handleKey);
+  }, [open, onCancel]);
+
+  if (!open) return null;
+
+  const isDanger = variant === "danger";
+
+  return (
+    <div
+      ref={overlayRef}
+      className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm fade-in"
+      onClick={(e) => { if (e.target === overlayRef.current) onCancel(); }}
+    >
+      <div className="bg-card border border-border rounded-2xl shadow-2xl shadow-primary/10 w-full max-w-md mx-4 overflow-hidden chat-msg-assistant">
+        {/* Header */}
+        <div className="flex items-center justify-between px-6 pt-6 pb-2">
+          <div className="flex items-center gap-3">
+            {isDanger && (
+              <div className="w-10 h-10 rounded-xl bg-destructive/10 flex items-center justify-center">
+                <AlertTriangle size={20} className="text-destructive" />
+              </div>
+            )}
+            <h3 className="text-lg font-semibold">{title}</h3>
+          </div>
+          <button
+            onClick={onCancel}
+            className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
+          >
+            <X size={16} />
+          </button>
+        </div>
+
+        {/* Body */}
+        <div className="px-6 py-4">
+          <p className="text-sm text-muted-foreground leading-relaxed">{message}</p>
+        </div>
+
+        {/* Footer */}
+        <div className="flex justify-end gap-3 px-6 pb-6">
+          <button
+            onClick={onCancel}
+            className="px-4 py-2.5 text-sm font-medium rounded-xl bg-secondary text-foreground hover:bg-secondary/80 transition-all border border-border/50"
+          >
+            {cancelLabel}
+          </button>
+          <button
+            onClick={onConfirm}
+            className={`px-4 py-2.5 text-sm font-bold rounded-xl transition-all hover:scale-105 active:scale-95 shadow-sm ${
+              isDanger
+                ? "bg-destructive text-white hover:shadow-destructive/20"
+                : "bg-primary text-primary-foreground hover:shadow-primary/20"
+            }`}
+          >
+            {confirmLabel}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 182 - 72
packages/studio/src/components/Sidebar.tsx

@@ -1,5 +1,21 @@
+import { useEffect } from "react";
 import { useApi } from "../hooks/use-api";
+import type { SSEMessage } from "../hooks/use-sse";
+import { shouldRefetchBookCollections, shouldRefetchDaemonStatus } from "../hooks/use-book-activity";
 import type { TFunction } from "../hooks/use-i18n";
+import {
+  Book,
+  Settings,
+  Terminal,
+  Plus,
+  ScrollText,
+  Boxes,
+  Zap,
+  Wand2,
+  FileInput,
+  TrendingUp,
+  Stethoscope,
+} from "lucide-react";
 
 interface BookSummary {
   readonly id: string;
@@ -17,89 +33,177 @@ interface Nav {
   toDaemon: () => void;
   toLogs: () => void;
   toGenres: () => void;
+  toStyle: () => void;
+  toImport: () => void;
+  toRadar: () => void;
+  toDoctor: () => void;
 }
 
-export function Sidebar({ nav, activePage, t }: {
+export function Sidebar({ nav, activePage, sse, t }: {
   nav: Nav;
   activePage: string;
+  sse: { messages: ReadonlyArray<SSEMessage> };
   t: TFunction;
 }) {
-  const { data } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
-  const { data: daemon } = useApi<{ running: boolean }>("/daemon");
+  const { data, refetch: refetchBooks } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
+  const { data: daemon, refetch: refetchDaemon } = useApi<{ running: boolean }>("/daemon");
+
+  useEffect(() => {
+    const recent = sse.messages.at(-1);
+    if (!recent) return;
+    if (shouldRefetchBookCollections(recent)) {
+      refetchBooks();
+    }
+    if (shouldRefetchDaemonStatus(recent)) {
+      refetchDaemon();
+    }
+  }, [refetchBooks, refetchDaemon, sse.messages]);
 
   return (
-    <aside className="w-[240px] shrink-0 border-r border-border bg-card/40 flex flex-col h-full overflow-y-auto">
-      {/* Logo — generous vertical breathing room */}
-      <div className="px-6 pt-7 pb-6">
-        <button onClick={nav.toDashboard} className="flex items-baseline gap-0.5 hover:opacity-70 transition-opacity">
-          <span className="font-serif text-2xl italic text-primary">Ink</span>
-          <span className="text-lg font-semibold tracking-tight">OS</span>
+    <aside className="w-[260px] shrink-0 border-r border-border bg-background/80 backdrop-blur-md flex flex-col h-full overflow-hidden select-none">
+      {/* Logo Area */}
+      <div className="px-6 py-8">
+        <button 
+          onClick={nav.toDashboard} 
+          className="group flex items-center gap-2 hover:opacity-80 transition-all duration-300"
+        >
+          <div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-primary-foreground shadow-lg shadow-primary/20 group-hover:scale-105 transition-transform">
+            <ScrollText size={18} />
+          </div>
+          <div className="flex flex-col">
+            <span className="font-serif text-xl leading-none italic font-medium">InkOS</span>
+            <span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold mt-1">Studio</span>
+          </div>
         </button>
       </div>
 
-      {/* Books section */}
-      <div className="flex-1 px-4">
-        <div className="px-2 mb-3 flex items-center justify-between">
-          <span className="text-sm uppercase tracking-wide text-muted-foreground font-medium">{t("nav.books")}</span>
-          <button
-            onClick={nav.toBookCreate}
-            className="w-6 h-6 flex items-center justify-center rounded text-sm text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all"
-          >
-            +
-          </button>
-        </div>
-
-        <div className="space-y-1">
-          {data?.books.map((book) => (
+      {/* Main Navigation */}
+      <div className="flex-1 overflow-y-auto px-4 py-2 space-y-6">
+        {/* Books Section */}
+        <div>
+          <div className="px-3 mb-3 flex items-center justify-between">
+            <span className="text-[11px] uppercase tracking-widest text-muted-foreground font-bold">
+              {t("nav.books")}
+            </span>
             <button
-              key={book.id}
-              onClick={() => nav.toBook(book.id)}
-              className={`w-full text-left px-3 py-2.5 rounded-md text-base truncate transition-all duration-150 ${
-                activePage === `book:${book.id}`
-                  ? "bg-primary/10 text-primary font-medium"
-                  : "text-foreground/80 hover:text-foreground hover:bg-muted/40"
-              }`}
+              onClick={nav.toBookCreate}
+              className="p-1 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all group"
+              title={t("nav.newBook")}
             >
-              {book.title}
+              <Plus size={14} className="group-hover:rotate-90 transition-transform duration-300" />
             </button>
-          ))}
+          </div>
 
-          {(!data?.books || data.books.length === 0) && (
-            <div className="px-3 py-4 text-xs text-muted-foreground/50 italic leading-relaxed">
-              {t("dash.noBooks")}
-            </div>
-          )}
+          <div className="space-y-1">
+            {data?.books.map((book) => (
+              <button
+                key={book.id}
+                onClick={() => nav.toBook(book.id)}
+                className={`w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${
+                  activePage === `book:${book.id}`
+                    ? "bg-primary/10 text-primary font-semibold"
+                    : "text-foreground font-medium hover:text-foreground hover:bg-secondary/50"
+                }`}
+              >
+                <Book size={16} className={activePage === `book:${book.id}` ? "text-primary" : "text-muted-foreground group-hover:text-foreground"} />
+                <span className="truncate flex-1 text-left">{book.title}</span>
+                {book.chaptersWritten > 0 && (
+                  <span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground group-hover:bg-primary/20 group-hover:text-primary transition-colors">
+                    {book.chaptersWritten}
+                  </span>
+                )}
+              </button>
+            ))}
+
+            {(!data?.books || data.books.length === 0) && (
+              <div className="px-3 py-6 text-xs text-muted-foreground/70 italic text-center border border-dashed border-border rounded-lg">
+                {t("dash.noBooks")}
+              </div>
+            )}
+          </div>
+        </div>
+
+        {/* System Section */}
+        <div>
+          <div className="px-3 mb-3">
+            <span className="text-[11px] uppercase tracking-widest text-muted-foreground font-bold">
+              {t("nav.system")}
+            </span>
+          </div>
+          <div className="space-y-1">
+            <SidebarItem
+              label={t("create.genre")}
+              icon={<Boxes size={16} />}
+              active={activePage === "genres"}
+              onClick={nav.toGenres}
+            />
+            <SidebarItem
+              label={t("nav.config")}
+              icon={<Settings size={16} />}
+              active={activePage === "config"}
+              onClick={nav.toConfig}
+            />
+            <SidebarItem
+              label={t("nav.daemon")}
+              icon={<Zap size={16} />}
+              active={activePage === "daemon"}
+              onClick={nav.toDaemon}
+              badge={daemon?.running ? t("nav.running") : undefined}
+              badgeColor={daemon?.running ? "bg-emerald-500/10 text-emerald-500" : "bg-muted text-muted-foreground"}
+            />
+            <SidebarItem
+              label={t("nav.logs")}
+              icon={<Terminal size={16} />}
+              active={activePage === "logs"}
+              onClick={nav.toLogs}
+            />
+          </div>
+        </div>
+
+        {/* Tools Section */}
+        <div>
+          <div className="px-3 mb-3">
+            <span className="text-[11px] uppercase tracking-widest text-muted-foreground font-bold">
+              {t("nav.tools")}
+            </span>
+          </div>
+          <div className="space-y-1">
+            <SidebarItem
+              label={t("nav.style")}
+              icon={<Wand2 size={16} />}
+              active={activePage === "style"}
+              onClick={nav.toStyle}
+            />
+            <SidebarItem
+              label={t("nav.import")}
+              icon={<FileInput size={16} />}
+              active={activePage === "import"}
+              onClick={nav.toImport}
+            />
+            <SidebarItem
+              label={t("nav.radar")}
+              icon={<TrendingUp size={16} />}
+              active={activePage === "radar"}
+              onClick={nav.toRadar}
+            />
+            <SidebarItem
+              label={t("nav.doctor")}
+              icon={<Stethoscope size={16} />}
+              active={activePage === "doctor"}
+              onClick={nav.toDoctor}
+            />
+          </div>
         </div>
       </div>
 
-      {/* System nav — generous spacing from book list */}
-      <div className="border-t border-border mt-4 pt-4 pb-5 px-4 space-y-1">
-        <SidebarItem
-          label={t("create.genre")}
-          icon="◈"
-          active={activePage === "genres"}
-          onClick={nav.toGenres}
-        />
-        <SidebarItem
-          label={t("nav.config")}
-          icon="⚙"
-          active={activePage === "config"}
-          onClick={nav.toConfig}
-        />
-        <SidebarItem
-          label="Daemon"
-          icon="⟳"
-          active={activePage === "daemon"}
-          onClick={nav.toDaemon}
-          badge={daemon?.running ? "●" : undefined}
-          badgeColor={daemon?.running ? "text-emerald-500" : undefined}
-        />
-        <SidebarItem
-          label="Logs"
-          icon="☰"
-          active={activePage === "logs"}
-          onClick={nav.toLogs}
-        />
+      {/* Footer / Status Area */}
+      <div className="p-4 border-t border-border bg-secondary/40">
+        <div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-card border border-border shadow-sm">
+          <div className={`w-2 h-2 rounded-full ${daemon?.running ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40"}`} />
+          <span className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider">
+            {daemon?.running ? t("nav.agentOnline") : t("nav.agentOffline")}
+          </span>
+        </div>
       </div>
     </aside>
   );
@@ -107,7 +211,7 @@ export function Sidebar({ nav, activePage, t }: {
 
 function SidebarItem({ label, icon, active, onClick, badge, badgeColor }: {
   label: string;
-  icon: string;
+  icon: React.ReactNode;
   active: boolean;
   onClick: () => void;
   badge?: string;
@@ -116,15 +220,21 @@ function SidebarItem({ label, icon, active, onClick, badge, badgeColor }: {
   return (
     <button
       onClick={onClick}
-      className={`w-full text-left px-3 py-2.5 rounded-md text-base flex items-center gap-2.5 transition-all duration-150 ${
+      className={`w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${
         active
-          ? "bg-secondary text-foreground font-medium"
-          : "text-foreground/70 hover:text-foreground hover:bg-muted/40"
+          ? "bg-secondary text-foreground font-semibold shadow-sm border border-border"
+          : "text-foreground font-medium hover:text-foreground hover:bg-secondary/50"
       }`}
     >
-      <span className="text-sm w-4 text-center opacity-60">{icon}</span>
-      <span className="flex-1">{label}</span>
-      {badge && <span className={`text-xs ${badgeColor ?? ""}`}>{badge}</span>}
+      <span className={`transition-colors ${active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"}`}>
+        {icon}
+      </span>
+      <span className="flex-1 text-left">{label}</span>
+      {badge && (
+        <span className={`text-[9px] px-1.5 py-0.5 rounded-full font-bold uppercase tracking-tight ${badgeColor}`}>
+          {badge}
+        </span>
+      )}
     </button>
   );
 }

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

@@ -37,6 +37,8 @@ const strings = {
   "reader.reject": { zh: "驳回", en: "Reject" },
   "reader.chapterList": { zh: "章节列表", en: "Chapter List" },
   "reader.characters": { zh: "字符", en: "characters" },
+  "reader.edit": { zh: "编辑", en: "Edit" },
+  "reader.preview": { zh: "预览", en: "Preview" },
 
   // Book Create
   "create.title": { zh: "创建书籍", en: "Create Book" },
@@ -72,6 +74,192 @@ const strings = {
   "config.provider": { zh: "提供方", en: "Provider" },
   "config.model": { zh: "模型", en: "Model" },
   "config.editHint": { zh: "通过 CLI 编辑配置:", en: "Edit via CLI:" },
+
+  // Sidebar
+  "nav.system": { zh: "系统", en: "System" },
+  "nav.daemon": { zh: "守护进程", en: "Daemon" },
+  "nav.logs": { zh: "日志", en: "Logs" },
+  "nav.running": { zh: "运行中", en: "Running" },
+  "nav.agentOnline": { zh: "代理在线", en: "Agent Online" },
+  "nav.agentOffline": { zh: "代理离线", en: "Agent Offline" },
+  "nav.tools": { zh: "工具", en: "Tools" },
+  "nav.style": { zh: "文风", en: "Style" },
+  "nav.import": { zh: "导入", en: "Import" },
+  "nav.radar": { zh: "市场雷达", en: "Radar" },
+  "nav.doctor": { zh: "环境诊断", en: "Doctor" },
+
+  // Book Detail extras
+  "book.deleteBook": { zh: "删除书籍", en: "Delete Book" },
+  "book.confirmDelete": { zh: "确认删除此书及所有章节?", en: "Delete this book and all chapters?" },
+  "book.settings": { zh: "书籍设置", en: "Book Settings" },
+  "book.status": { zh: "状态", en: "Status" },
+  "book.drafting": { zh: "草稿中...", en: "Drafting..." },
+  "book.pipelineWriting": { zh: "后台正在写作,本页会在完成后自动刷新。", en: "Background writing is running. This page will refresh automatically when it finishes." },
+  "book.pipelineDrafting": { zh: "后台正在生成草稿,本页会在完成后自动刷新。", en: "Background drafting is running. This page will refresh automatically when it finishes." },
+  "book.pipelineFailed": { zh: "后台任务失败", en: "Background job failed" },
+  "book.save": { zh: "保存", en: "Save" },
+  "book.saving": { zh: "保存中...", en: "Saving..." },
+  "book.rewrite": { zh: "重写", en: "Rewrite" },
+  "book.audit": { zh: "审计", en: "Audit" },
+  "book.export": { zh: "导出", en: "Export" },
+  "book.approvedOnly": { zh: "仅已通过", en: "Approved Only" },
+  "book.manuscriptTitle": { zh: "章节标题", en: "Manuscript Title" },
+  "book.curate": { zh: "操作", en: "Actions" },
+  "book.spotFix": { zh: "精修", en: "Spot Fix" },
+  "book.polish": { zh: "打磨", en: "Polish" },
+  "book.rework": { zh: "重作", en: "Rework" },
+  "book.antiDetect": { zh: "反检测", en: "Anti-Detect" },
+  "book.statusActive": { zh: "进行中", en: "Active" },
+  "book.statusPaused": { zh: "已暂停", en: "Paused" },
+  "book.statusOutlining": { zh: "大纲中", en: "Outlining" },
+  "book.statusCompleted": { zh: "已完成", en: "Completed" },
+  "book.statusDropped": { zh: "已放弃", en: "Dropped" },
+  "book.truthFiles": { zh: "真相文件", en: "Truth Files" },
+
+  // Style
+  "style.title": { zh: "文风分析", en: "Style Analyzer" },
+  "style.sourceName": { zh: "来源名称", en: "Source Name" },
+  "style.sourceExample": { zh: "如:参考小说", en: "e.g. Reference Novel" },
+  "style.textSample": { zh: "文本样本", en: "Text Sample" },
+  "style.pasteHint": { zh: "粘贴参考文本进行文风分析...", en: "Paste reference text for style analysis..." },
+  "style.analyze": { zh: "分析", en: "Analyze" },
+  "style.analyzing": { zh: "分析中...", en: "Analyzing..." },
+  "style.results": { zh: "分析结果", en: "Analysis Results" },
+  "style.avgSentence": { zh: "平均句长", en: "Avg Sentence Length" },
+  "style.vocabDiversity": { zh: "词汇多样性", en: "Vocabulary Diversity" },
+  "style.avgParagraph": { zh: "平均段落长度", en: "Avg Paragraph Length" },
+  "style.sentenceStdDev": { zh: "句长标准差", en: "Sentence StdDev" },
+  "style.topPatterns": { zh: "主要模式", en: "Top Patterns" },
+  "style.rhetoricalFeatures": { zh: "修辞特征", en: "Rhetorical Features" },
+  "style.importToBook": { zh: "导入到书籍", en: "Import to Book" },
+  "style.selectBook": { zh: "选择书籍...", en: "Select book..." },
+  "style.importGuide": { zh: "导入文风指南", en: "Import Style Guide" },
+  "style.emptyHint": { zh: "粘贴文本并点击分析查看文风档案", en: "Paste text and click Analyze to see style profile" },
+
+  // Import
+  "import.title": { zh: "导入工具", en: "Import Tools" },
+  "import.chapters": { zh: "导入章节", en: "Import Chapters" },
+  "import.canon": { zh: "导入母本", en: "Import Canon" },
+  "import.fanfic": { zh: "同人创作", en: "Fanfic" },
+  "import.selectTarget": { zh: "选择目标书籍...", en: "Select target book..." },
+  "import.splitRegex": { zh: "分割正则(可选)", en: "Split regex (optional)" },
+  "import.pasteChapters": { zh: "粘贴章节文本...", en: "Paste chapter text..." },
+  "import.selectSource": { zh: "选择源(母本)...", en: "Select source (parent)..." },
+  "import.selectDerivative": { zh: "选择目标(衍生)...", en: "Select target (derivative)..." },
+  "import.fanficTitle": { zh: "同人小说标题", en: "Fanfic title" },
+  "import.pasteMaterial": { zh: "粘贴原作文本/设定/角色资料...", en: "Paste source material..." },
+  "import.importing": { zh: "导入中...", en: "Importing..." },
+  "import.creating": { zh: "创建中...", en: "Creating..." },
+
+  // Radar
+  "radar.title": { zh: "市场雷达", en: "Market Radar" },
+  "radar.scan": { zh: "扫描市场", en: "Scan Market" },
+  "radar.scanning": { zh: "扫描中...", en: "Scanning..." },
+  "radar.summary": { zh: "市场概要", en: "Market Summary" },
+  "radar.emptyHint": { zh: "点击「扫描市场」分析当前趋势和机会", en: "Click \"Scan Market\" to analyze trends and opportunities" },
+
+  // Doctor
+  "doctor.title": { zh: "环境诊断", en: "Environment Check" },
+  "doctor.recheck": { zh: "重新检查", en: "Re-check" },
+  "doctor.inkosJson": { zh: "inkos.json 配置", en: "inkos.json configuration" },
+  "doctor.projectEnv": { zh: "项目 .env 文件", en: "Project .env file" },
+  "doctor.globalEnv": { zh: "全局 ~/.inkos/.env", en: "Global ~/.inkos/.env" },
+  "doctor.booksDir": { zh: "书籍目录", en: "Books directory" },
+  "doctor.llmApi": { zh: "LLM API 连接", en: "LLM API connectivity" },
+  "doctor.connected": { zh: "已连接", en: "Connected" },
+  "doctor.failed": { zh: "失败", en: "Failed" },
+  "doctor.allPassed": { zh: "所有检查通过 — 环境健康", en: "All checks passed — environment is healthy" },
+  "doctor.someFailed": { zh: "部分检查失败 — 请查看配置", en: "Some checks failed — review configuration" },
+
+  // Genre extras
+  "genre.createNew": { zh: "创建新题材", en: "Create New Genre" },
+  "genre.editGenre": { zh: "编辑", en: "Edit" },
+  "genre.deleteGenre": { zh: "删除", en: "Delete" },
+  "genre.confirmDelete": { zh: "确认删除此题材?", en: "Delete this genre?" },
+  "genre.chapterTypes": { zh: "章节类型", en: "Chapter Types" },
+  "genre.fatigueWords": { zh: "疲劳词", en: "Fatigue Words" },
+  "genre.numericalSystem": { zh: "数值系统", en: "Numerical System" },
+  "genre.powerScaling": { zh: "力量等级", en: "Power Scaling" },
+  "genre.eraResearch": { zh: "时代研究", en: "Era Research" },
+  "genre.pacingRule": { zh: "节奏规则", en: "Pacing Rule" },
+  "genre.rules": { zh: "规则", en: "Rules" },
+  "genre.saveChanges": { zh: "保存更改", en: "Save Changes" },
+  "genre.cancel": { zh: "取消", en: "Cancel" },
+  "genre.copyToProject": { zh: "复制到项目", en: "Copy to Project" },
+  "genre.selectHint": { zh: "选择题材查看详情", en: "Select a genre to view details" },
+  "genre.commaSeparated": { zh: "逗号分隔", en: "comma-separated" },
+  "genre.rulesMd": { zh: "规则(Markdown)", en: "Rules (Markdown)" },
+
+  // Config extras
+  "config.modelRouting": { zh: "模型路由", en: "Model Routing" },
+  "config.agent": { zh: "代理", en: "Agent" },
+  "config.baseUrl": { zh: "基础 URL", en: "Base URL" },
+  "config.default": { zh: "默认", en: "default" },
+  "config.optional": { zh: "可选", en: "optional" },
+  "config.saveOverrides": { zh: "保存路由", en: "Save Overrides" },
+  "config.save": { zh: "保存", en: "Save" },
+  "config.saving": { zh: "保存中...", en: "Saving..." },
+  "config.cancel": { zh: "取消", en: "Cancel" },
+  "config.edit": { zh: "编辑", en: "Edit" },
+  "config.enabled": { zh: "启用", en: "Enabled" },
+  "config.disabled": { zh: "禁用", en: "Disabled" },
+
+  // Truth Files extras
+  "truth.title": { zh: "真相文件", en: "Truth Files" },
+  "truth.edit": { zh: "编辑", en: "Edit" },
+  "truth.save": { zh: "保存", en: "Save" },
+  "truth.saving": { zh: "保存中...", en: "Saving..." },
+  "truth.cancel": { zh: "取消", en: "Cancel" },
+  "truth.noFiles": { zh: "暂无文件", en: "No truth files" },
+  "truth.notFound": { zh: "文件未找到", en: "File not found" },
+  "truth.selectHint": { zh: "选择文件查看内容", en: "Select a file to view" },
+
+  // Dashboard
+  "dash.subtitle": { zh: "管理你的文学宇宙和 AI 辅助草稿。", en: "Manage your literary universe and AI-assisted drafts." },
+
+  // Chapter Reader extras
+  "reader.openingManuscript": { zh: "打开书稿中...", en: "Opening manuscript..." },
+  "reader.manuscriptPage": { zh: "书稿页", en: "Manuscript Page" },
+  "reader.minRead": { zh: "分钟阅读", en: "min read" },
+  "reader.endOfChapter": { zh: "本章完", en: "End of Chapter" },
+
+  // Daemon Control
+  "daemon.title": { zh: "守护进程控制", en: "Daemon Control" },
+  "daemon.running": { zh: "运行中", en: "Running" },
+  "daemon.stopped": { zh: "已停止", en: "Stopped" },
+  "daemon.start": { zh: "启动", en: "Start" },
+  "daemon.stop": { zh: "停止", en: "Stop" },
+  "daemon.starting": { zh: "启动中...", en: "Starting..." },
+  "daemon.stopping": { zh: "停止中...", en: "Stopping..." },
+  "daemon.waitingEvents": { zh: "等待事件...", en: "Waiting for events..." },
+  "daemon.startHint": { zh: "启动守护进程查看事件", en: "Start the daemon to see events" },
+
+  // Config extras (labels)
+  "config.temperature": { zh: "温度", en: "Temperature" },
+  "config.maxTokens": { zh: "最大令牌数", en: "Max Tokens" },
+  "config.stream": { zh: "流式输出", en: "Stream" },
+  "config.chinese": { zh: "中文", en: "Chinese" },
+  "config.english": { zh: "英文", en: "English" },
+
+  // BookCreate extras
+  "create.platform": { zh: "平台", en: "Platform" },
+
+  // Common
+  "common.save": { zh: "保存", en: "Save" },
+  "common.cancel": { zh: "取消", en: "Cancel" },
+  "common.delete": { zh: "删除", en: "Delete" },
+  "common.edit": { zh: "编辑", en: "Edit" },
+  "common.loading": { zh: "加载中...", en: "Loading..." },
+  "common.enterCommand": { zh: "输入指令...", en: "Enter command..." },
+  "chapter.readyForReview": { zh: "待审核", en: "Ready for Review" },
+  "chapter.approved": { zh: "已通过", en: "Approved" },
+  "chapter.drafted": { zh: "草稿", en: "Drafted" },
+  "chapter.needsRevision": { zh: "需修订", en: "Needs Revision" },
+  "chapter.imported": { zh: "已导入", en: "Imported" },
+  "chapter.auditFailed": { zh: "审计失败", en: "Audit Failed" },
+  "chapter.label": { zh: "第{n}章", en: "Chapter {n}" },
+  "common.exportSuccess": { zh: "已导出到项目目录", en: "Exported to project directory" },
+  "common.exportFormat": { zh: "导出格式", en: "Export format" },
 } as const;
 
 export type StringKey = keyof typeof strings;

+ 252 - 56
packages/studio/src/index.css

@@ -1,8 +1,11 @@
-@import "tailwindcss";
-
-/* Instrument Serif for literary headings + DM Sans for clean UI */
+/* 
+  InkOS Studio — Atmospheric Literary Workbench
+  Fonts: Instrument Serif (Headings/Literary), DM Sans (UI), JetBrains Mono (Data)
+*/
 @import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
 
+@import "tailwindcss";
+
 @theme inline {
   --color-background: var(--background);
   --color-foreground: var(--foreground);
@@ -23,91 +26,284 @@
   --color-border: var(--border);
   --color-input: var(--input);
   --color-ring: var(--ring);
-  --color-amber: var(--amber);
-  --color-amber-foreground: var(--amber-foreground);
+  
   --radius-sm: calc(var(--radius) - 4px);
   --radius-md: calc(var(--radius) - 2px);
   --radius-lg: var(--radius);
   --radius-xl: calc(var(--radius) + 4px);
+  
   --font-serif: 'Instrument Serif', Georgia, serif;
   --font-sans: 'DM Sans', system-ui, sans-serif;
   --font-mono: 'JetBrains Mono', monospace;
+
+  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+  --shadow-soft: 0 2px 15px -3px oklch(0 0 0 / 0.07), 0 4px 6px -4px oklch(0 0 0 / 0.05);
+  --shadow-3d: 0 10px 30px -10px oklch(0 0 0 / 0.15), 0 4px 10px -5px oklch(0 0 0 / 0.1);
+  --shadow-3d-hover: 0 20px 40px -15px oklch(0 0 0 / 0.2), 0 8px 16px -8px oklch(0 0 0 / 0.15);
 }
 
-/* ── Light: warm parchment ── */
+/* ── Light Mode: Warm Parchment & Ink ── */
 :root {
-  --radius: 0.375rem;
-  --background: oklch(0.97 0.008 80);
-  --foreground: oklch(0.18 0.015 55);
-  --card: oklch(0.99 0.005 80);
-  --card-foreground: oklch(0.18 0.015 55);
-  --popover: oklch(0.99 0.005 80);
-  --popover-foreground: oklch(0.18 0.015 55);
-  --primary: oklch(0.62 0.14 55);
-  --primary-foreground: oklch(0.99 0.005 80);
-  --secondary: oklch(0.93 0.01 80);
-  --secondary-foreground: oklch(0.25 0.02 55);
-  --muted: oklch(0.93 0.008 80);
-  --muted-foreground: oklch(0.42 0.03 55);
-  --accent: oklch(0.93 0.012 55);
-  --accent-foreground: oklch(0.25 0.02 55);
-  --destructive: oklch(0.55 0.2 25);
+  --radius: 0.6rem;
+  
+  /* Background: Warm, aged paper with subtle radial light */
+  --background: oklch(0.985 0.005 80);
+  --background-radial: radial-gradient(circle at 50% 0%, oklch(0.995 0.002 85) 0%, oklch(0.985 0.005 80) 100%);
+  --foreground: oklch(0.13 0.02 60);
+
+  /* Cards: Crisp sheets on paper */
+  --card: oklch(1 0 0);
+  --card-foreground: oklch(0.15 0.015 60);
+
+  --popover: oklch(1 0 0);
+  --popover-foreground: oklch(0.15 0.015 60);
+  
+  /* Primary: Deep Oxblood / Ink */
+  --primary: oklch(0.45 0.12 25);
+  --primary-foreground: oklch(0.98 0.006 76);
+  
+  /* Secondary: Soft paper shadow */
+  --secondary: oklch(0.94 0.01 76);
+  --secondary-foreground: oklch(0.25 0.015 60);
+
+  /* Muted: Faded ink */
+  --muted: oklch(0.94 0.008 76);
+  --muted-foreground: oklch(0.38 0.02 60);
+
+  /* Accent: Subtle gold leaf */
+  --accent: oklch(0.92 0.02 85);
+  --accent-foreground: oklch(0.25 0.05 60);
+  
+  --destructive: oklch(0.55 0.18 25);
   --destructive-foreground: oklch(0.98 0 0);
-  --border: oklch(0.82 0.015 75);
-  --input: oklch(0.82 0.015 75);
-  --ring: oklch(0.62 0.14 55);
-  --amber: oklch(0.75 0.15 70);
-  --amber-foreground: oklch(0.25 0.05 55);
+  
+  --border: oklch(0.84 0.01 76);
+  --input: oklch(0.92 0.005 76);
+  --ring: oklch(0.45 0.12 25);
 }
 
-/* ── Dark: neutral charcoal + amber accent ── */
+/* ── Dark Mode: Obsidian & Candlelight ── */
 .dark {
-  --background: #111111;
-  --foreground: #e8e6e3;
-  --card: #191919;
-  --card-foreground: #e8e6e3;
-  --popover: #191919;
-  --popover-foreground: #e8e6e3;
-  --primary: #d4a046;
-  --primary-foreground: #111111;
-  --secondary: #252525;
-  --secondary-foreground: #d4d0cb;
-  --muted: #252525;
-  --muted-foreground: #a09a93;
-  --accent: #2a2826;
-  --accent-foreground: #d4d0cb;
-  --destructive: #e5584f;
-  --destructive-foreground: #ffffff;
-  --border: #333333;
-  --input: #2a2a2a;
-  --ring: #d4a046;
-  --amber: #d4a046;
-  --amber-foreground: #111111;
+  /* Background: Deep obsidian with soft top glow */
+  --background: oklch(0.12 0.01 250);
+  --background-radial: radial-gradient(circle at 50% 0%, oklch(0.16 0.015 250) 0%, oklch(0.12 0.01 250) 100%);
+  --foreground: oklch(0.97 0.005 250);
+
+  /* Cards: Floating graphite */
+  --card: oklch(0.18 0.015 250);
+  --card-foreground: oklch(0.95 0.005 250);
+
+  --popover: oklch(0.18 0.015 250);
+  --popover-foreground: oklch(0.95 0.005 250);
+  
+  /* Primary: Warm Amber / Candlelight */
+  --primary: oklch(0.78 0.14 85);
+  --primary-foreground: oklch(0.12 0.01 250);
+  
+  /* Secondary: Dark slate */
+  --secondary: oklch(0.22 0.02 250);
+  --secondary-foreground: oklch(0.92 0.005 250);
+
+  /* Muted: Dimmed graphite */
+  --muted: oklch(0.22 0.015 250);
+  --muted-foreground: oklch(0.78 0.005 250);
+
+  /* Accent: Muted gold */
+  --accent: oklch(0.28 0.04 85);
+  --accent-foreground: oklch(0.95 0.06 85);
+  
+  --destructive: oklch(0.55 0.2 25);
+  --destructive-foreground: oklch(0.98 0 0);
+  
+  --border: oklch(0.30 0.02 250);
+  --input: oklch(0.25 0.02 250);
+  --ring: oklch(0.78 0.14 85);
 }
 
 body {
   font-family: var(--font-sans);
   font-size: 15px;
+  font-weight: 450;
   line-height: 1.6;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
-  background-color: var(--background);
+  background: var(--background-radial);
+  background-attachment: fixed;
   color: var(--foreground);
+  transition: background 0.5s ease, color 0.5s ease;
 }
 
-/* Subtle grain texture */
+/* ── Cursor interaction rules ── */
+button, [role="button"], a, select, label[for], summary { cursor: pointer; }
+button:disabled, [aria-disabled="true"] { cursor: not-allowed; }
+input[type="text"], input[type="number"], input[type="email"], input[type="password"], textarea { cursor: text; }
+input[type="checkbox"], input[type="radio"] { cursor: pointer; }
+
+/* Button hover micro-feedback */
+button:not(:disabled):active {
+  transform: scale(0.97);
+  transition: transform 0.1s ease;
+}
+
+/* Subtle grain texture for physical feel */
 body::before {
   content: '';
   position: fixed;
   inset: 0;
   pointer-events: none;
   z-index: 50;
-  opacity: 0.03;
-  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
+  opacity: 0.035;
+  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
+  mix-blend-mode: overlay;
 }
 
-/* Serif headings */
-h1, h2, h3 {
+/* Serif headings with literary character */
+h1, h2, h3, h4 {
   font-family: var(--font-serif);
-  letter-spacing: -0.01em;
+  letter-spacing: -0.02em;
+  font-weight: 500;
+}
+
+h1 { font-size: 2.25rem; font-style: italic; }
+h2 { font-size: 1.75rem; }
+h3 { font-size: 1.25rem; }
+
+/* Focus indicator */
+:focus-visible {
+  outline: 2px solid var(--ring);
+  outline-offset: 2px;
+}
+
+/* Custom Scrollbar */
+::-webkit-scrollbar {
+  width: 6px;
+  height: 6px;
+}
+
+::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+  background: var(--border);
+  border-radius: 10px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+  background: var(--muted-foreground);
+}
+
+/* ── Animation Classes ── */
+.fade-in {
+  animation: fadeIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
+}
+
+.stagger-1 { animation-delay: 0.05s; opacity: 0; }
+.stagger-2 { animation-delay: 0.1s; opacity: 0; }
+.stagger-3 { animation-delay: 0.15s; opacity: 0; }
+.stagger-4 { animation-delay: 0.2s; opacity: 0; }
+.stagger-5 { animation-delay: 0.25s; opacity: 0; }
+
+@keyframes fadeIn {
+  from { opacity: 0; transform: translateY(8px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+/* ── Premium Layout Utilities ── */
+.glass-panel {
+  backdrop-blur: 16px;
+  background-color: color-mix(in oklch, var(--card) 70%, transparent);
+  border: 1px solid color-mix(in oklch, var(--border) 50%, transparent);
+  box-shadow: var(--shadow-3d);
+}
+
+.paper-sheet {
+  background-color: var(--card);
+  border: 1px solid var(--border);
+  box-shadow: var(--shadow-md);
+  transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
+  transform-style: preserve-3d;
+}
+
+.paper-sheet:hover {
+  box-shadow: var(--shadow-3d-hover);
+  transform: translateY(-4px) scale(1.005);
+  border-color: var(--primary);
+}
+
+/* ── ChatPanel Animations ── */
+
+/* Header sparkle glow pulse */
+@keyframes iconGlow {
+  0%, 100% { filter: drop-shadow(0 0 0px transparent); }
+  50% { filter: drop-shadow(0 0 6px oklch(0.78 0.14 85 / 0.4)); }
+}
+.chat-icon-glow { animation: iconGlow 2s ease-in-out infinite; }
+
+/* Writing pen gentle bobbing */
+@keyframes iconWrite {
+  0%, 100% { transform: translateY(0) rotate(0deg); }
+  25% { transform: translateY(-1px) rotate(-3deg); }
+  75% { transform: translateY(1px) rotate(3deg); }
+}
+.chat-icon-write { animation: iconWrite 1.2s ease-in-out infinite; }
+
+/* Slow spin for revision/wrench */
+@keyframes spinSlow {
+  from { transform: rotate(0deg); }
+  to { transform: rotate(360deg); }
+}
+.chat-icon-spin-slow { animation: spinSlow 3s linear infinite; }
+
+/* Pop-in for success checkmark */
+@keyframes iconPop {
+  0% { transform: scale(0); opacity: 0; }
+  60% { transform: scale(1.2); }
+  100% { transform: scale(1); opacity: 1; }
+}
+.chat-icon-pop { animation: iconPop 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
+
+/* Shake for trash icon hover */
+@keyframes shake {
+  0%, 100% { transform: translateX(0); }
+  25% { transform: translateX(-2px) rotate(-5deg); }
+  75% { transform: translateX(2px) rotate(5deg); }
+}
+
+/* Message slide-in from right (user) */
+@keyframes msgSlideRight {
+  from { opacity: 0; transform: translateX(12px); }
+  to { opacity: 1; transform: translateX(0); }
+}
+.chat-msg-user { animation: msgSlideRight 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
+
+/* Message slide-in from left (assistant) */
+@keyframes msgSlideLeft {
+  from { opacity: 0; transform: translateX(-12px); }
+  to { opacity: 1; transform: translateX(0); }
+}
+.chat-msg-assistant { animation: msgSlideLeft 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
+
+/* Thinking breathing glow */
+@keyframes thinkGlow {
+  0%, 100% { box-shadow: 0 0 0 0 oklch(0.78 0.14 85 / 0); }
+  50% { box-shadow: 0 0 12px 2px oklch(0.78 0.14 85 / 0.15); }
+}
+.chat-thinking-glow { animation: thinkGlow 2s ease-in-out infinite; }
+
+/* Typing wave for dots */
+@keyframes typingWave {
+  0%, 60%, 100% { transform: translateY(0); }
+  30% { transform: translateY(-6px); }
+}
+.chat-typing-dot { animation: typingWave 1.4s ease-in-out infinite; }
+.chat-typing-dot:nth-child(2) { animation-delay: 0.15s; }
+.chat-typing-dot:nth-child(3) { animation-delay: 0.3s; }
+
+/* Panel slide transition */
+.chat-panel-enter {
+  transition: width 300ms cubic-bezier(0.16, 1, 0.3, 1),
+              opacity 200ms ease-out;
 }

+ 465 - 135
packages/studio/src/pages/BookDetail.tsx

@@ -1,8 +1,30 @@
-import { useApi, postApi } from "../hooks/use-api";
-import { useState } from "react";
+import { fetchJson, useApi, postApi } from "../hooks/use-api";
+import { useEffect, useMemo, useState } from "react";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
+import type { SSEMessage } from "../hooks/use-sse";
 import { useColors } from "../hooks/use-colors";
+import { deriveBookActivity, shouldRefetchBookView } from "../hooks/use-book-activity";
+import { ConfirmDialog } from "../components/ConfirmDialog";
+import {
+  ChevronLeft,
+  Zap,
+  FileText,
+  CheckCheck,
+  BarChart2,
+  Download,
+  Search,
+  Wand2,
+  Eye,
+  Database,
+  Check,
+  X,
+  ShieldCheck,
+  RotateCcw,
+  Sparkles,
+  Trash2,
+  Save
+} from "lucide-react";
 
 interface ChapterMeta {
   readonly number: number;
@@ -18,6 +40,7 @@ interface BookData {
     readonly genre: string;
     readonly status: string;
     readonly chapterWordCount: number;
+    readonly targetChapters?: number;
     readonly language?: string;
     readonly fanficMode?: string;
   };
@@ -25,47 +48,174 @@ interface BookData {
   readonly nextChapter: number;
 }
 
+type ReviseMode = "spot-fix" | "polish" | "rewrite" | "rework" | "anti-detect";
+type ExportFormat = "txt" | "md" | "epub";
+type BookStatus = "active" | "paused" | "outlining" | "completed" | "dropped";
+
 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",
+function translateChapterStatus(status: string, t: TFunction): string {
+  const map: Record<string, () => string> = {
+    "ready-for-review": () => t("chapter.readyForReview"),
+    "approved": () => t("chapter.approved"),
+    "drafted": () => t("chapter.drafted"),
+    "needs-revision": () => t("chapter.needsRevision"),
+    "imported": () => t("chapter.imported"),
+    "audit-failed": () => t("chapter.auditFailed"),
+  };
+  return map[status]?.() ?? status;
+}
+
+const STATUS_CONFIG: Record<string, { color: string; icon: React.ReactNode }> = {
+  "ready-for-review": { color: "text-amber-500 bg-amber-500/10", icon: <Eye size={12} /> },
+  approved: { color: "text-emerald-500 bg-emerald-500/10", icon: <Check size={12} /> },
+  drafted: { color: "text-muted-foreground bg-muted/20", icon: <FileText size={12} /> },
+  "needs-revision": { color: "text-destructive bg-destructive/10", icon: <RotateCcw size={12} /> },
+  imported: { color: "text-blue-500 bg-blue-500/10", icon: <Download size={12} /> },
 };
 
-export function BookDetail({ bookId, nav, theme, t }: { bookId: string; nav: Nav; theme: Theme; t: TFunction }) {
+export function BookDetail({
+  bookId,
+  nav,
+  theme,
+  t,
+  sse,
+}: {
+  bookId: string;
+  nav: Nav;
+  theme: Theme;
+  t: TFunction;
+  sse: { messages: ReadonlyArray<SSEMessage> };
+}) {
   const c = useColors(theme);
   const { data, loading, error, refetch } = useApi<BookData>(`/books/${bookId}`);
-  const [writing, setWriting] = useState(false);
-  const [drafting, setDrafting] = useState(false);
+  const [writeRequestPending, setWriteRequestPending] = useState(false);
+  const [draftRequestPending, setDraftRequestPending] = useState(false);
+  const [deleting, setDeleting] = useState(false);
+  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
+  const [rewritingChapters, setRewritingChapters] = useState<ReadonlyArray<number>>([]);
+  const [revisingChapters, setRevisingChapters] = useState<ReadonlyArray<number>>([]);
+  const [savingSettings, setSavingSettings] = useState(false);
+  const [settingsWordCount, setSettingsWordCount] = useState<number | null>(null);
+  const [settingsTargetChapters, setSettingsTargetChapters] = useState<number | null>(null);
+  const [settingsStatus, setSettingsStatus] = useState<BookStatus | null>(null);
+  const [exportFormat, setExportFormat] = useState<ExportFormat>("txt");
+  const [exportApprovedOnly, setExportApprovedOnly] = useState(false);
+  const activity = useMemo(() => deriveBookActivity(sse.messages, bookId), [bookId, sse.messages]);
+  const writing = writeRequestPending || activity.writing;
+  const drafting = draftRequestPending || activity.drafting;
+
+  useEffect(() => {
+    const recent = sse.messages.at(-1);
+    if (!recent) return;
+
+    const data = recent.data as { bookId?: string } | null;
+    if (data?.bookId !== bookId) return;
+
+    if (recent.event === "write:start") {
+      setWriteRequestPending(false);
+      return;
+    }
+
+    if (recent.event === "draft:start") {
+      setDraftRequestPending(false);
+      return;
+    }
+
+    if (shouldRefetchBookView(recent, bookId)) {
+      setWriteRequestPending(false);
+      setDraftRequestPending(false);
+      refetch();
+    }
+  }, [bookId, refetch, sse.messages]);
 
   const handleWriteNext = async () => {
-    setWriting(true);
+    setWriteRequestPending(true);
     try {
       await postApi(`/books/${bookId}/write-next`);
-      refetch();
     } catch (e) {
+      setWriteRequestPending(false);
       alert(e instanceof Error ? e.message : "Failed");
-    } finally {
-      setWriting(false);
     }
   };
 
   const handleDraft = async () => {
-    setDrafting(true);
+    setDraftRequestPending(true);
     try {
       await postApi(`/books/${bookId}/draft`);
-      refetch();
     } catch (e) {
+      setDraftRequestPending(false);
       alert(e instanceof Error ? e.message : "Failed");
+    }
+  };
+
+  const handleDeleteBook = async () => {
+    setConfirmDeleteOpen(false);
+    setDeleting(true);
+    try {
+      const res = await fetch(`/api/books/${bookId}`, { method: "DELETE" });
+      if (!res.ok) {
+        const json = await res.json().catch(() => ({}));
+        throw new Error((json as { error?: string }).error ?? `${res.status}`);
+      }
+      nav.toDashboard();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Delete failed");
+    } finally {
+      setDeleting(false);
+    }
+  };
+
+  const handleRewrite = async (chapterNum: number) => {
+    setRewritingChapters((prev) => [...prev, chapterNum]);
+    try {
+      await postApi(`/books/${bookId}/rewrite/${chapterNum}`);
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Rewrite failed");
+    } finally {
+      setRewritingChapters((prev) => prev.filter((n) => n !== chapterNum));
+    }
+  };
+
+  const handleRevise = async (chapterNum: number, mode: ReviseMode) => {
+    setRevisingChapters((prev) => [...prev, chapterNum]);
+    try {
+      await fetchJson(`/books/${bookId}/revise/${chapterNum}`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ mode }),
+      });
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Revision failed");
+    } finally {
+      setRevisingChapters((prev) => prev.filter((n) => n !== chapterNum));
+    }
+  };
+
+  const handleSaveSettings = async () => {
+    if (!data) return;
+    setSavingSettings(true);
+    try {
+      const body: Record<string, unknown> = {};
+      if (settingsWordCount !== null) body.chapterWordCount = settingsWordCount;
+      if (settingsTargetChapters !== null) body.targetChapters = settingsTargetChapters;
+      if (settingsStatus !== null) body.status = settingsStatus;
+      await fetchJson(`/books/${bookId}`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(body),
+      });
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Save failed");
     } finally {
-      setDrafting(false);
+      setSavingSettings(false);
     }
   };
 
@@ -78,172 +228,352 @@ export function BookDetail({ bookId, nav, theme, t }: { bookId: string; nav: Nav
     refetch();
   };
 
-  if (loading) return <div className={c.muted}>Loading...</div>;
-  if (error) return <div className="text-red-400">Error: {error}</div>;
+  if (loading) return (
+    <div className="flex flex-col items-center justify-center py-32 space-y-4">
+      <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
+      <span className="text-sm text-muted-foreground">{t("common.loading")}</span>
+    </div>
+  );
+
+  if (error) return <div className="text-destructive p-8 bg-destructive/5 rounded-xl border border-destructive/20">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;
 
+  const currentWordCount = settingsWordCount ?? book.chapterWordCount;
+  const currentTargetChapters = settingsTargetChapters ?? book.targetChapters ?? 0;
+  const currentStatus = settingsStatus ?? (book.status as BookStatus);
+
+  const exportHref = `/api/books/${bookId}/export?format=${exportFormat}${exportApprovedOnly ? "&approvedOnly=true" : ""}`;
+
   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="space-y-8 fade-in">
+      {/* Breadcrumbs */}
+      <nav className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
+        <button 
+          onClick={nav.toDashboard} 
+          className="hover:text-primary transition-colors flex items-center gap-1"
+        >
+          <ChevronLeft size={14} />
+          {t("bread.books")}
+        </button>
+        <span className="text-border">/</span>
+        <span className="text-foreground">{book.title}</span>
+      </nav>
 
-      <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>}
+      {/* Header Section */}
+      <div className="flex flex-col md:flex-row md:items-end justify-between gap-6 border-b border-border/40 pb-8">
+        <div className="space-y-2">
+          <div className="flex items-center gap-3">
+            <h1 className="text-4xl font-serif font-medium">{book.title}</h1>
+            {book.language === "en" && (
+              <span className="px-1.5 py-0.5 rounded border border-primary/20 text-primary text-[10px] font-bold">EN</span>
+            )}
+          </div>
+          <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground font-medium">
+            <span className="px-2 py-0.5 rounded bg-secondary/50 text-foreground/70 uppercase tracking-wider text-xs">{book.genre}</span>
+            <div className="flex items-center gap-1.5">
+              <FileText size={14} />
+              <span>{chapters.length} {t("dash.chapters")}</span>
+            </div>
+            <div className="flex items-center gap-1.5">
+              <Zap size={14} />
+              <span>{totalWords.toLocaleString()} {t("book.words")}</span>
+            </div>
+            {book.fanficMode && (
+              <span className="flex items-center gap-1 text-purple-500">
+                <Sparkles size={12} />
+                <span className="italic">fanfic:{book.fanficMode}</span>
+              </span>
+            )}
           </div>
         </div>
 
-        <div className="flex gap-2">
+        <div className="flex flex-wrap gap-2">
           <button
             onClick={handleWriteNext}
             disabled={writing || drafting}
-            className={`px-4 py-2 text-sm ${c.btnPrimary} rounded-md transition-colors disabled:opacity-50`}
+            className="flex items-center gap-2 px-5 py-2.5 text-sm font-bold bg-primary text-primary-foreground rounded-xl hover:scale-105 active:scale-95 transition-all shadow-lg shadow-primary/20 disabled:opacity-50"
           >
-            {writing ? "Writing..." : t("book.writeNext")}
+            {writing ? <div className="w-4 h-4 border-2 border-primary-foreground/20 border-t-primary-foreground rounded-full animate-spin" /> : <Zap size={16} />}
+            {writing ? t("dash.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`}
+            className="flex items-center gap-2 px-5 py-2.5 text-sm font-bold bg-secondary text-foreground rounded-xl hover:bg-secondary/80 transition-all border border-border/50 disabled:opacity-50"
           >
-            {drafting ? "Drafting..." : t("book.draftOnly")}
+            {drafting ? <div className="w-4 h-4 border-2 border-muted-foreground/20 border-t-muted-foreground rounded-full animate-spin" /> : <Wand2 size={16} />}
+            {drafting ? t("book.drafting") : t("book.draftOnly")}
           </button>
+          <button
+            onClick={() => setConfirmDeleteOpen(true)}
+            disabled={deleting}
+            className="flex items-center gap-2 px-5 py-2.5 text-sm font-bold bg-destructive/10 text-destructive rounded-xl hover:bg-destructive hover:text-white transition-all border border-destructive/20 disabled:opacity-50"
+          >
+            {deleting ? <div className="w-4 h-4 border-2 border-destructive/20 border-t-destructive rounded-full animate-spin" /> : <Trash2 size={16} />}
+            {deleting ? t("common.loading") : t("book.deleteBook")}
+          </button>
+        </div>
+      </div>
+
+      {(writing || drafting || activity.lastError) && (
+        <div
+          className={`rounded-2xl border px-4 py-3 text-sm ${
+            activity.lastError
+              ? "border-destructive/30 bg-destructive/5 text-destructive"
+              : "border-primary/20 bg-primary/[0.04] text-foreground"
+          }`}
+        >
+          {activity.lastError ? (
+            <span>
+              {t("book.pipelineFailed")}: {activity.lastError}
+            </span>
+          ) : writing ? (
+            <span>{t("book.pipelineWriting")}</span>
+          ) : (
+            <span>{t("book.pipelineDrafting")}</span>
+          )}
+        </div>
+      )}
+
+      {/* Tool Strip */}
+      <div className="flex flex-wrap items-center gap-2 py-1">
           {reviewCount > 0 && (
             <button
               onClick={handleApproveAll}
-              className={`px-4 py-2 text-sm ${c.btnSuccess} rounded-md transition-colors`}
+              className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-emerald-500/10 text-emerald-600 rounded-lg hover:bg-emerald-500/20 transition-all border border-emerald-500/20"
             >
+              <CheckCheck size={14} />
               {t("book.approveAll")} ({reviewCount})
             </button>
           )}
           <button
             onClick={() => (nav as { toTruth?: (id: string) => void }).toTruth?.(bookId)}
-            className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors`}
+            className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg hover:text-foreground hover:bg-secondary transition-all border border-border/50"
           >
-            Truth Files
+            <Database size={14} />
+            {t("book.truthFiles")}
           </button>
           <button
             onClick={() => nav.toAnalytics(bookId)}
-            className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors`}
+            className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg hover:text-foreground hover:bg-secondary transition-all border border-border/50"
           >
+            <BarChart2 size={14} />
             {t("book.analytics")}
           </button>
-          <a
-            href={`/api/books/${bookId}/export?format=txt`}
-            download
-            className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors inline-flex items-center`}
+          <div className="flex items-center gap-2">
+            <select
+              value={exportFormat}
+              onChange={(e) => setExportFormat(e.target.value as ExportFormat)}
+              className="px-2 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg border border-border/50 outline-none"
+            >
+              <option value="txt">TXT</option>
+              <option value="md">MD</option>
+              <option value="epub">EPUB</option>
+            </select>
+            <label className="flex items-center gap-1.5 text-xs font-bold text-muted-foreground cursor-pointer select-none">
+              <input
+                type="checkbox"
+                checked={exportApprovedOnly}
+                onChange={(e) => setExportApprovedOnly(e.target.checked)}
+                className="rounded border-border/50"
+              />
+              {t("book.approvedOnly")}
+            </label>
+            <button
+              onClick={async () => {
+                try {
+                  const data = await fetchJson<{ path?: string; chapters?: number }>(`/books/${bookId}/export-save`, {
+                    method: "POST",
+                    headers: { "Content-Type": "application/json" },
+                    body: JSON.stringify({ format: exportFormat, approvedOnly: exportApprovedOnly }),
+                  });
+                  alert(`${t("common.exportSuccess")}\n${data.path}\n(${data.chapters} ${t("dash.chapters")})`);
+                } catch (e) {
+                  alert(e instanceof Error ? e.message : "Export failed");
+                }
+              }}
+              className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary/50 text-muted-foreground rounded-lg hover:text-foreground hover:bg-secondary transition-all border border-border/50"
+            >
+              <Download size={14} />
+              {t("book.export")}
+            </button>
+          </div>
+      </div>
+
+      {/* Book Settings */}
+      <div className="paper-sheet rounded-2xl border border-border/40 shadow-sm p-6">
+        <h2 className="text-sm font-bold uppercase tracking-widest text-muted-foreground mb-4">{t("book.settings")}</h2>
+        <div className="flex flex-wrap items-end gap-4">
+          <div className="flex flex-col gap-1">
+            <label className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground">{t("create.wordsPerChapter")}</label>
+            <input
+              type="number"
+              value={currentWordCount}
+              onChange={(e) => setSettingsWordCount(Number(e.target.value))}
+              className="px-3 py-2 text-sm rounded-lg border border-border/50 bg-secondary/30 outline-none focus:border-primary/50 w-32"
+            />
+          </div>
+          <div className="flex flex-col gap-1">
+            <label className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground">{t("create.targetChapters")}</label>
+            <input
+              type="number"
+              value={currentTargetChapters}
+              onChange={(e) => setSettingsTargetChapters(Number(e.target.value))}
+              className="px-3 py-2 text-sm rounded-lg border border-border/50 bg-secondary/30 outline-none focus:border-primary/50 w-32"
+            />
+          </div>
+          <div className="flex flex-col gap-1">
+            <label className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground">{t("book.status")}</label>
+            <select
+              value={currentStatus}
+              onChange={(e) => setSettingsStatus(e.target.value as BookStatus)}
+              className="px-3 py-2 text-sm rounded-lg border border-border/50 bg-secondary/30 outline-none focus:border-primary/50"
+            >
+              <option value="active">{t("book.statusActive")}</option>
+              <option value="paused">{t("book.statusPaused")}</option>
+              <option value="outlining">{t("book.statusOutlining")}</option>
+              <option value="completed">{t("book.statusCompleted")}</option>
+              <option value="dropped">{t("book.statusDropped")}</option>
+            </select>
+          </div>
+          <button
+            onClick={handleSaveSettings}
+            disabled={savingSettings}
+            className="flex items-center gap-2 px-4 py-2 text-sm font-bold bg-primary text-primary-foreground rounded-lg hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
           >
-            Export
-          </a>
+            {savingSettings ? <div className="w-4 h-4 border-2 border-primary-foreground/20 border-t-primary-foreground rounded-full animate-spin" /> : <Save size={14} />}
+            {savingSettings ? t("book.saving") : t("book.save")}
+          </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">
-                  <div className="flex gap-1 justify-end">
-                    {ch.status === "ready-for-review" && (
-                      <>
-                        <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>
-                      </>
-                    )}
-                    <button
-                      onClick={async () => {
-                        const r = await fetch(`/api/books/${bookId}/audit/${ch.number}`, { method: "POST" });
-                        const data = await r.json();
-                        alert(data.passed ? "Audit passed" : `Audit failed: ${data.issues?.length ?? 0} issues`);
-                        refetch();
-                      }}
-                      className={`px-2 py-1 text-xs ${c.btnSecondary} rounded`}
-                    >
-                      Audit
-                    </button>
-                    <button
-                      onClick={async () => {
-                        await fetch(`/api/books/${bookId}/revise/${ch.number}`, {
-                          method: "POST",
-                          headers: { "Content-Type": "application/json" },
-                          body: JSON.stringify({ mode: "spot-fix" }),
-                        });
-                        alert("Revision started");
-                        refetch();
-                      }}
-                      className={`px-2 py-1 text-xs ${c.btnSecondary} rounded`}
-                    >
-                      Revise
-                    </button>
+      {/* Chapters Table */}
+      <div className="paper-sheet rounded-2xl overflow-hidden border border-border/40 shadow-xl shadow-primary/5">
+        <div className="overflow-x-auto">
+          <table className="w-full text-sm border-collapse">
+            <thead>
+              <tr className="bg-muted/30 border-b border-border/50">
+                <th className="text-left px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground w-16">#</th>
+                <th className="text-left px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground">{t("book.manuscriptTitle")}</th>
+                <th className="text-left px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground w-28">{t("book.words")}</th>
+                <th className="text-left px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground w-36">{t("book.status")}</th>
+                <th className="text-right px-6 py-4 font-bold text-[11px] uppercase tracking-widest text-muted-foreground">{t("book.curate")}</th>
+              </tr>
+            </thead>
+            <tbody className="divide-y divide-border/30">
+              {chapters.map((ch, index) => {
+                const staggerClass = `stagger-${Math.min(index + 1, 5)}`;
+                return (
+                <tr key={ch.number} className={`group hover:bg-primary/[0.02] transition-colors fade-in ${staggerClass}`}>
+                  <td className="px-6 py-4 text-muted-foreground/60 font-mono text-xs">{ch.number.toString().padStart(2, '0')}</td>
+                  <td className="px-6 py-4">
                     <button
-                      onClick={async () => {
-                        const r = await fetch(`/api/books/${bookId}/detect/${ch.number}`, { method: "POST" });
-                        const data = await r.json();
-                        alert(`AI-tell: ${data.issues?.length ?? 0} issues found`);
-                      }}
-                      className={`px-2 py-1 text-xs ${c.btnSecondary} rounded`}
+                      onClick={() => nav.toChapter(bookId, ch.number)}
+                      className="font-serif text-lg font-medium hover:text-primary transition-colors text-left"
                     >
-                      Detect
+                      {ch.title || t("chapter.label").replace("{n}", String(ch.number))}
                     </button>
-                  </div>
-                </td>
-              </tr>
-            ))}
-          </tbody>
-        </table>
+                  </td>
+                  <td className="px-6 py-4 text-muted-foreground font-medium tabular-nums text-xs">{(ch.wordCount ?? 0).toLocaleString()}</td>
+                  <td className="px-6 py-4">
+                    <div className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-tight ${STATUS_CONFIG[ch.status]?.color ?? "bg-muted text-muted-foreground"}`}>
+                      {STATUS_CONFIG[ch.status]?.icon}
+                      {translateChapterStatus(ch.status, t)}
+                    </div>
+                  </td>
+                  <td className="px-6 py-4 text-right">
+                    <div className="flex gap-1.5 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
+                      {ch.status === "ready-for-review" && (
+                        <>
+                          <button
+                            onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/approve`); refetch(); }}
+                            className="p-2 rounded-lg bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500 hover:text-white transition-all shadow-sm"
+                            title={t("book.approve")}
+                          >
+                            <Check size={14} />
+                          </button>
+                          <button
+                            onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/reject`); refetch(); }}
+                            className="p-2 rounded-lg bg-destructive/10 text-destructive hover:bg-destructive hover:text-white transition-all shadow-sm"
+                            title={t("book.reject")}
+                          >
+                            <X size={14} />
+                          </button>
+                        </>
+                      )}
+                      <button
+                        onClick={async () => {
+                          const auditResult = await fetchJson<{ passed?: boolean; issues?: unknown[] }>(`/books/${bookId}/audit/${ch.number}`, { method: "POST" });
+                          alert(auditResult.passed ? "Audit passed" : `Audit failed: ${auditResult.issues?.length ?? 0} issues`);
+                          refetch();
+                        }}
+                        className="p-2 rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm"
+                        title={t("book.audit")}
+                      >
+                        <ShieldCheck size={14} />
+                      </button>
+                      <button
+                        onClick={() => handleRewrite(ch.number)}
+                        disabled={rewritingChapters.includes(ch.number)}
+                        className="p-2 rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm disabled:opacity-50"
+                        title={t("book.rewrite")}
+                      >
+                        {rewritingChapters.includes(ch.number)
+                          ? <div className="w-3.5 h-3.5 border-2 border-muted-foreground/20 border-t-muted-foreground rounded-full animate-spin" />
+                          : <RotateCcw size={14} />}
+                      </button>
+                      <select
+                        disabled={revisingChapters.includes(ch.number)}
+                        value=""
+                        onChange={(e) => {
+                          const mode = e.target.value as ReviseMode;
+                          if (mode) handleRevise(ch.number, mode);
+                        }}
+                        className="px-2 py-1.5 text-[11px] font-bold rounded-lg bg-secondary text-muted-foreground border border-border/50 outline-none hover:text-primary hover:bg-primary/10 transition-all disabled:opacity-50 cursor-pointer"
+                        title="Revise with AI"
+                      >
+                        <option value="" disabled>{revisingChapters.includes(ch.number) ? t("common.loading") : t("book.curate")}</option>
+                        <option value="spot-fix">{t("book.spotFix")}</option>
+                        <option value="polish">{t("book.polish")}</option>
+                        <option value="rewrite">{t("book.rewrite")}</option>
+                        <option value="rework">{t("book.rework")}</option>
+                        <option value="anti-detect">{t("book.antiDetect")}</option>
+                      </select>
+                    </div>
+                  </td>
+                </tr>
+                );
+              })}
+            </tbody>
+          </table>
+        </div>
 
         {chapters.length === 0 && (
-          <div className={`text-center py-12 ${c.muted}`}>
-            {t("book.noChapters")}
+          <div className="flex flex-col items-center justify-center py-20 text-center">
+            <div className="w-12 h-12 rounded-full bg-muted/20 flex items-center justify-center mb-4">
+               <FileText size={20} className="text-muted-foreground/40" />
+            </div>
+            <p className="text-sm italic font-serif text-muted-foreground">
+              {t("book.noChapters")}
+            </p>
           </div>
         )}
       </div>
+
+      <ConfirmDialog
+        open={confirmDeleteOpen}
+        title={t("book.deleteBook")}
+        message={t("book.confirmDelete")}
+        confirmLabel={t("common.delete")}
+        cancelLabel={t("common.cancel")}
+        variant="danger"
+        onConfirm={handleDeleteBook}
+        onCancel={() => setConfirmDeleteOpen(false)}
+      />
     </div>
   );
 }

+ 175 - 33
packages/studio/src/pages/ChapterReader.tsx

@@ -1,7 +1,24 @@
-import { useApi, postApi } from "../hooks/use-api";
+import { useState } from "react";
+import { fetchJson, 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";
+import {
+  ChevronLeft,
+  Check,
+  X,
+  List,
+  RotateCcw,
+  BookOpen,
+  CheckCircle2,
+  XCircle,
+  Hash,
+  Type,
+  Clock,
+  Pencil,
+  Save,
+  Eye,
+} from "lucide-react";
 
 interface ChapterData {
   readonly chapterNumber: number;
@@ -22,12 +39,49 @@ export function ChapterReader({ bookId, chapterNumber, nav, theme, t }: {
   t: TFunction;
 }) {
   const c = useColors(theme);
-  const { data, loading, error } = useApi<ChapterData>(
+  const { data, loading, error, refetch } = useApi<ChapterData>(
     `/books/${bookId}/chapters/${chapterNumber}`,
   );
+  const [editing, setEditing] = useState(false);
+  const [editContent, setEditContent] = useState("");
+  const [saving, setSaving] = useState(false);
 
-  if (loading) return <div className={c.muted}>Loading...</div>;
-  if (error) return <div className="text-red-400">Error: {error}</div>;
+  const handleStartEdit = () => {
+    if (!data) return;
+    setEditContent(data.content);
+    setEditing(true);
+  };
+
+  const handleCancelEdit = () => {
+    setEditing(false);
+    setEditContent("");
+  };
+
+  const handleSave = async () => {
+    setSaving(true);
+    try {
+      await fetchJson(`/books/${bookId}/chapters/${chapterNumber}`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ content: editContent }),
+      });
+      setEditing(false);
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Save failed");
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  if (loading) return (
+    <div className="flex flex-col items-center justify-center py-32 space-y-4">
+      <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
+      <span className="text-sm text-muted-foreground">{t("reader.openingManuscript")}</span>
+    </div>
+  );
+
+  if (error) return <div className="text-destructive p-8 bg-destructive/5 rounded-xl border border-destructive/20">Error: {error}</div>;
   if (!data) return null;
 
   // Split markdown content into title and body
@@ -49,67 +103,155 @@ export function ChapterReader({ bookId, chapterNumber, nav, theme, t }: {
     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="max-w-4xl mx-auto space-y-10 fade-in">
+      {/* Navigation & Actions */}
+      <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
+        <nav className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
+          <button 
+            onClick={nav.toDashboard} 
+            className="hover:text-primary transition-colors flex items-center gap-1"
+          >
+            {t("bread.books")}
+          </button>
+          <span className="text-border">/</span>
+          <button 
+            onClick={() => nav.toBook(bookId)} 
+            className="hover:text-primary transition-colors truncate max-w-[120px]"
+          >
+            {bookId}
+          </button>
+          <span className="text-border">/</span>
+          <span className="text-foreground flex items-center gap-1">
+            <Hash size={12} />
+            {chapterNumber}
+          </span>
+        </nav>
 
-      <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="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary text-muted-foreground rounded-xl hover:text-foreground hover:bg-secondary/80 transition-all border border-border/50"
+          >
+            <List size={14} />
+            {t("reader.backToList")}
+          </button>
+
+          {/* Edit / Preview toggle */}
+          {editing ? (
+            <>
+              <button
+                onClick={handleSave}
+                disabled={saving}
+                className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-primary text-primary-foreground rounded-xl hover:scale-105 active:scale-95 transition-all shadow-sm disabled:opacity-50"
+              >
+                {saving ? <div className="w-3.5 h-3.5 border-2 border-primary-foreground/20 border-t-primary-foreground rounded-full animate-spin" /> : <Save size={14} />}
+                {saving ? t("book.saving") : t("book.save")}
+              </button>
+              <button
+                onClick={handleCancelEdit}
+                className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary text-muted-foreground rounded-xl hover:text-foreground transition-all border border-border/50"
+              >
+                <Eye size={14} />
+                {t("reader.preview")}
+              </button>
+            </>
+          ) : (
             <button
-              onClick={() => nav.toBook(bookId)}
-              className={`px-3 py-1.5 text-sm ${c.btnSecondary} rounded-md`}
+              onClick={handleStartEdit}
+              className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-secondary text-muted-foreground rounded-xl hover:text-primary hover:bg-primary/10 transition-all border border-border/50"
             >
-              {t("reader.backToList")}
+              <Pencil size={14} />
+              {t("reader.edit")}
             </button>
           )}
+
           <button
             onClick={handleApprove}
-            className={`px-3 py-1.5 text-sm ${c.btnSuccess} rounded-md`}
+            className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-emerald-500/10 text-emerald-600 rounded-xl hover:bg-emerald-500 hover:text-white transition-all border border-emerald-500/20 shadow-sm"
           >
+            <CheckCircle2 size={14} />
             {t("reader.approve")}
           </button>
           <button
             onClick={handleReject}
-            className={`px-3 py-1.5 text-sm ${c.btnDanger} rounded-md`}
+            className="flex items-center gap-2 px-4 py-2 text-xs font-bold bg-destructive/10 text-destructive rounded-xl hover:bg-destructive hover:text-white transition-all border border-destructive/20 shadow-sm"
           >
+            <XCircle size={14} />
             {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>
+      {/* Manuscript Sheet */}
+      <div className="paper-sheet rounded-2xl p-8 md:p-16 lg:p-24 shadow-2xl shadow-primary/5 min-h-[80vh] relative overflow-hidden">
+        {/* Physical Paper Details */}
+        <div className="absolute top-0 left-8 w-px h-full bg-primary/5 hidden md:block" />
+        <div className="absolute top-0 right-8 w-px h-full bg-primary/5 hidden md:block" />
+        
+        <header className="mb-16 text-center">
+          <div className="flex items-center justify-center gap-2 text-muted-foreground/30 mb-8 select-none">
+            <div className="h-px w-12 bg-border/40" />
+            <BookOpen size={20} />
+            <div className="h-px w-12 bg-border/40" />
+          </div>
+          <h1 className="text-4xl md:text-5xl font-serif font-medium italic text-foreground tracking-tight leading-tight">
+            {title}
+          </h1>
+          <div className="mt-8 flex items-center justify-center gap-4 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground/60">
+            <span>{t("reader.manuscriptPage")}</span>
+            <span className="text-border">·</span>
+            <span>{chapterNumber.toString().padStart(2, '0')}</span>
+          </div>
+        </header>
+
+        {editing ? (
+          <textarea
+            value={editContent}
+            onChange={(e) => setEditContent(e.target.value)}
+            className="w-full min-h-[60vh] bg-transparent font-serif text-lg leading-[1.8] text-foreground/90 focus:outline-none resize-none border border-border/30 rounded-lg p-6 focus:border-primary/40 focus:ring-2 focus:ring-primary/10 transition-all"
+            autoFocus
+          />
+        ) : (
+          <article className="prose prose-zinc dark:prose-invert max-w-none">
+            {paragraphs.map((para, i) => (
+              <p key={i} className="font-serif text-lg md:text-xl leading-[1.8] text-foreground/90 mb-8 first-letter:text-2xl first-letter:font-bold first-letter:text-primary/40">
+                {para}
+              </p>
+            ))}
+          </article>
+        )}
+        
+        <footer className="mt-24 pt-12 border-t border-border/20 flex flex-col items-center gap-6 text-center">
+          <div className="flex items-center gap-4 text-xs font-medium text-muted-foreground">
+             <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-secondary/50">
+               <Type size={14} className="text-primary/60" />
+               <span>{body.length.toLocaleString()} {t("reader.characters")}</span>
+             </div>
+             <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-secondary/50">
+               <Clock size={14} className="text-primary/60" />
+               <span>{Math.ceil(body.length / 500)} {t("reader.minRead")}</span>
+             </div>
+          </div>
+          <p className="text-[10px] uppercase tracking-widest text-muted-foreground/40 font-bold">{t("reader.endOfChapter")}</p>
+        </footer>
+      </div>
 
-      <div className={`flex justify-between pt-8 border-t ${c.cardStatic} text-sm`}>
+      {/* Footer Navigation */}
+      <div className="flex justify-between items-center py-8">
         {chapterNumber > 1 ? (
           <button
             onClick={() => nav.toBook(bookId)}
-            className={`${c.subtle} ${c.link}`}
+            className="flex items-center gap-2 text-sm font-bold text-muted-foreground hover:text-primary transition-all group"
           >
+            <RotateCcw size={16} className="group-hover:-rotate-45 transition-transform" />
             {t("reader.chapterList")}
           </button>
         ) : (
           <div />
         )}
-        <div className={c.muted}>
-          {body.length.toLocaleString()} {t("reader.characters")}
-        </div>
       </div>
     </div>
   );

+ 155 - 15
packages/studio/src/pages/ConfigView.tsx

@@ -1,9 +1,26 @@
-import { useApi, postApi } from "../hooks/use-api";
-import { useState } from "react";
+import { fetchJson, useApi } from "../hooks/use-api";
+import { useEffect, useState } from "react";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import { useColors } from "../hooks/use-colors";
 
+const ROUTING_AGENTS = [
+  "writer",
+  "auditor",
+  "reviser",
+  "architect",
+  "radar",
+  "chapter-analyzer",
+] as const;
+
+interface AgentOverride {
+  readonly model: string;
+  readonly provider: string;
+  readonly baseUrl: string;
+}
+
+type OverridesMap = Record<string, AgentOverride>;
+
 interface ProjectInfo {
   readonly name: string;
   readonly language: string;
@@ -19,6 +36,17 @@ interface Nav {
   toDashboard: () => void;
 }
 
+export function normalizeOverridesDraft(
+  data?: { readonly overrides?: OverridesMap } | null,
+): OverridesMap {
+  return Object.fromEntries(
+    Object.entries(data?.overrides ?? {}).map(([agent, override]) => [
+      agent,
+      { ...override },
+    ]),
+  ) as OverridesMap;
+}
+
 export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
   const c = useColors(theme);
   const { data, loading, error, refetch } = useApi<ProjectInfo>("/project");
@@ -43,7 +71,7 @@ export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
   const handleSave = async () => {
     setSaving(true);
     try {
-      await fetch("/api/project", {
+      await fetchJson("/project", {
         method: "PUT",
         headers: { "Content-Type": "application/json" },
         body: JSON.stringify(form),
@@ -78,7 +106,7 @@ export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
         <Row label={t("config.project")} value={data.name} />
         <Row label={t("config.provider")} value={data.provider} />
         <Row label={t("config.model")} value={data.model} />
-        <Row label="Base URL" value={data.baseUrl} mono />
+        <Row label={t("config.baseUrl")} value={data.baseUrl} mono />
 
         {editing ? (
           <>
@@ -87,38 +115,38 @@ export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
               value={form.language as string}
               onChange={(v) => setForm({ ...form, language: v })}
               type="select"
-              options={[{ value: "zh", label: "Chinese" }, { value: "en", label: "English" }]}
+              options={[{ value: "zh", label: t("config.chinese") }, { value: "en", label: t("config.english") }]}
               c={c}
             />
             <EditRow
-              label="Temperature"
+              label={t("config.temperature")}
               value={String(form.temperature)}
               onChange={(v) => setForm({ ...form, temperature: parseFloat(v) })}
               type="number"
               c={c}
             />
             <EditRow
-              label="Max Tokens"
+              label={t("config.maxTokens")}
               value={String(form.maxTokens)}
               onChange={(v) => setForm({ ...form, maxTokens: parseInt(v, 10) })}
               type="number"
               c={c}
             />
             <EditRow
-              label="Stream"
+              label={t("config.stream")}
               value={String(form.stream)}
               onChange={(v) => setForm({ ...form, stream: v === "true" })}
               type="select"
-              options={[{ value: "true", label: "Enabled" }, { value: "false", label: "Disabled" }]}
+              options={[{ value: "true", label: t("config.enabled") }, { value: "false", label: t("config.disabled") }]}
               c={c}
             />
           </>
         ) : (
           <>
-            <Row label={t("config.language")} value={data.language === "en" ? "English" : "Chinese"} />
-            <Row label="Temperature" value={String(data.temperature)} mono />
-            <Row label="Max Tokens" value={String(data.maxTokens)} mono />
-            <Row label="Stream" value={data.stream ? "Enabled" : "Disabled"} />
+            <Row label={t("config.language")} value={data.language === "en" ? t("config.english") : t("config.chinese")} />
+            <Row label={t("config.temperature")} value={String(data.temperature)} mono />
+            <Row label={t("config.maxTokens")} value={String(data.maxTokens)} mono />
+            <Row label={t("config.stream")} value={data.stream ? t("config.enabled") : t("config.disabled")} />
           </>
         )}
       </div>
@@ -126,17 +154,129 @@ export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
       {editing && (
         <div className="flex gap-2 justify-end">
           <button onClick={() => setEditing(false)} className={`px-4 py-2.5 text-sm rounded-md ${c.btnSecondary}`}>
-            Cancel
+            {t("config.cancel")}
           </button>
           <button onClick={handleSave} disabled={saving} className={`px-4 py-2.5 text-sm rounded-md ${c.btnPrimary} disabled:opacity-50`}>
-            {saving ? "Saving..." : "Save"}
+            {saving ? t("config.saving") : t("config.save")}
           </button>
         </div>
       )}
+
+      <ModelRoutingSection theme={theme} t={t} />
     </div>
   );
 }
 
+function emptyOverride(): AgentOverride {
+  return { model: "", provider: "", baseUrl: "" };
+}
+
+function ModelRoutingSection({ theme, t }: { theme: Theme; t: TFunction }) {
+  const c = useColors(theme);
+  const { data, loading, error, refetch } = useApi<{ overrides: OverridesMap }>(
+    "/project/model-overrides",
+  );
+  const [overrides, setOverrides] = useState<OverridesMap>({});
+  const [saving, setSaving] = useState(false);
+
+  useEffect(() => {
+    setOverrides(normalizeOverridesDraft(data));
+  }, [data]);
+
+  if (loading) return <div className="text-muted-foreground py-8 text-center text-sm">Loading model overrides...</div>;
+  if (error) return <div className="text-destructive py-8 text-center text-sm">Error: {error}</div>;
+
+  const updateAgent = (agent: string, field: keyof AgentOverride, value: string) => {
+    const current = overrides[agent] ?? emptyOverride();
+    setOverrides({
+      ...overrides,
+      [agent]: { ...current, [field]: value },
+    });
+  };
+
+  const handleSave = async () => {
+    setSaving(true);
+    try {
+      await fetchJson("/project/model-overrides", {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ overrides }),
+      });
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Failed to save model overrides");
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  return (
+    <>
+      <h2 className="font-serif text-xl mt-4">{t("config.modelRouting")}</h2>
+
+      <div className={`border ${c.cardStatic} rounded-lg overflow-hidden`}>
+        <table className="w-full text-sm">
+          <thead>
+            <tr className="border-b border-border/40 text-muted-foreground text-left">
+              <th className="px-4 py-2.5 font-medium">{t("config.agent")}</th>
+              <th className="px-4 py-2.5 font-medium">{t("config.model")}</th>
+              <th className="px-4 py-2.5 font-medium">{t("config.provider")}</th>
+              <th className="px-4 py-2.5 font-medium">{t("config.baseUrl")}</th>
+            </tr>
+          </thead>
+          <tbody>
+            {ROUTING_AGENTS.map((agent) => {
+              const row = overrides[agent] ?? emptyOverride();
+              return (
+                <tr key={agent} className="border-b border-border/40 last:border-b-0">
+                  <td className="px-4 py-2 font-mono text-foreground/80">{agent}</td>
+                  <td className="px-4 py-2">
+                    <input
+                      type="text"
+                      value={row.model}
+                      onChange={(e) => updateAgent(agent, "model", e.target.value)}
+                      placeholder={t("config.default")}
+                      className={`${c.input} rounded px-2 py-1 text-sm w-full`}
+                    />
+                  </td>
+                  <td className="px-4 py-2">
+                    <input
+                      type="text"
+                      value={row.provider}
+                      onChange={(e) => updateAgent(agent, "provider", e.target.value)}
+                      placeholder={t("config.optional")}
+                      className={`${c.input} rounded px-2 py-1 text-sm w-full`}
+                    />
+                  </td>
+                  <td className="px-4 py-2">
+                    <input
+                      type="text"
+                      value={row.baseUrl}
+                      onChange={(e) => updateAgent(agent, "baseUrl", e.target.value)}
+                      placeholder={t("config.optional")}
+                      className={`${c.input} rounded px-2 py-1 text-sm w-full`}
+                    />
+                  </td>
+                </tr>
+              );
+            })}
+          </tbody>
+        </table>
+      </div>
+
+      <div className="flex justify-end">
+        <button
+          onClick={handleSave}
+          disabled={saving}
+          className={`px-4 py-2.5 text-sm rounded-md ${c.btnPrimary} disabled:opacity-50`}
+        >
+          {saving ? t("config.saving") : t("config.saveOverrides")}
+        </button>
+      </div>
+    </>
+  );
+}
+
 function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
   return (
     <div className="flex justify-between px-4 py-3">

+ 253 - 78
packages/studio/src/pages/Dashboard.tsx

@@ -1,9 +1,27 @@
-import { useApi, postApi } from "../hooks/use-api";
-import { useState, useMemo } from "react";
+import { fetchJson, useApi, postApi } from "../hooks/use-api";
+import { useEffect, useMemo, useState, useRef } 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";
+import { deriveActiveBookIds, shouldRefetchBookCollections } from "../hooks/use-book-activity";
+import { ConfirmDialog } from "../components/ConfirmDialog";
+import {
+  Plus,
+  BookOpen,
+  BarChart2,
+  Zap,
+  Clock,
+  CheckCircle2,
+  AlertCircle,
+  MoreVertical,
+  ChevronRight,
+  Flame,
+  Trash2,
+  Settings,
+  Download,
+  FileInput,
+} from "lucide-react";
 
 interface BookSummary {
   readonly id: string;
@@ -21,150 +39,307 @@ interface Nav {
   toBookCreate: () => void;
 }
 
+function BookMenu({ bookId, bookTitle, nav, t, onDelete }: {
+  readonly bookId: string;
+  readonly bookTitle: string;
+  readonly nav: Nav;
+  readonly t: TFunction;
+  readonly onDelete: () => void;
+}) {
+  const [open, setOpen] = useState(false);
+  const [confirmDelete, setConfirmDelete] = useState(false);
+  const menuRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!open) return;
+    const handleClick = (e: MouseEvent) => {
+      if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false);
+    };
+    document.addEventListener("mousedown", handleClick);
+    return () => document.removeEventListener("mousedown", handleClick);
+  }, [open]);
+
+  const handleDelete = async () => {
+    setConfirmDelete(false);
+    setOpen(false);
+    await fetchJson(`/books/${bookId}`, { method: "DELETE" });
+    onDelete();
+  };
+
+  return (
+    <div ref={menuRef} className="relative">
+      <button
+        onClick={() => setOpen((prev) => !prev)}
+        className="p-3 rounded-xl text-muted-foreground hover:text-primary hover:bg-primary/10 hover:scale-105 active:scale-95 transition-all cursor-pointer"
+      >
+        <MoreVertical size={18} />
+      </button>
+      {open && (
+        <div className="absolute right-0 top-full mt-1 w-44 bg-card border border-border rounded-xl shadow-lg shadow-primary/5 py-1 z-50 fade-in">
+          <button
+            onClick={() => { setOpen(false); nav.toBook(bookId); }}
+            className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-foreground hover:bg-secondary/50 transition-colors cursor-pointer"
+          >
+            <Settings size={14} className="text-muted-foreground" />
+            {t("book.settings")}
+          </button>
+          <a
+            href={`/api/books/${bookId}/export?format=txt`}
+            download
+            onClick={() => setOpen(false)}
+            className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-foreground hover:bg-secondary/50 transition-colors cursor-pointer"
+          >
+            <Download size={14} className="text-muted-foreground" />
+            {t("book.export")}
+          </a>
+          <div className="border-t border-border/50 my-1" />
+          <button
+            onClick={() => { setOpen(false); setConfirmDelete(true); }}
+            className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-destructive hover:bg-destructive/10 transition-colors cursor-pointer"
+          >
+            <Trash2 size={14} />
+            {t("book.deleteBook")}
+          </button>
+        </div>
+      )}
+      <ConfirmDialog
+        open={confirmDelete}
+        title={t("book.deleteBook")}
+        message={`${t("book.confirmDelete")}\n\n"${bookTitle}"`}
+        confirmLabel={t("common.delete")}
+        cancelLabel={t("common.cancel")}
+        variant="danger"
+        onConfirm={handleDelete}
+        onCancel={() => setConfirmDelete(false)}
+      />
+    </div>
+  );
+}
+
 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 writingBooks = useMemo(() => deriveActiveBookIds(sse.messages), [sse.messages]);
 
   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();
-      }
+  useEffect(() => {
+    const recent = sse.messages.at(-1);
+    if (!recent) return;
+    if (shouldRefetchBookCollections(recent)) {
+      refetch();
     }
-  }, [sse.messages.length]);
+  }, [refetch, sse.messages]);
 
-  if (loading) return <div className="text-muted-foreground py-20 text-center text-sm">Loading...</div>;
-  if (error) return <div className="text-destructive py-20 text-center">Error: {error}</div>;
+  if (loading) return (
+    <div className="flex flex-col items-center justify-center py-32 space-y-4">
+      <div className="w-8 h-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
+      <span className="text-sm text-muted-foreground animate-pulse">Gathering manuscripts...</span>
+    </div>
+  );
+
+  if (error) return (
+    <div className="flex flex-col items-center justify-center py-20 bg-destructive/5 border border-destructive/20 rounded-2xl">
+      <AlertCircle className="text-destructive mb-4" size={32} />
+      <h2 className="text-lg font-semibold text-destructive">Failed to load library</h2>
+      <p className="text-sm text-muted-foreground mt-1">{error}</p>
+    </div>
+  );
 
-  /* ── Empty state — vertically centered in the available viewport ── */
   if (!data?.books.length) {
     return (
-      <div className="flex flex-col items-center justify-center min-h-[60vh]">
-        <div className="text-5xl mb-8 opacity-15 select-none">✦</div>
-        <h2 className="font-serif text-3xl italic text-foreground/70 mb-3">{t("dash.noBooks")}</h2>
-        <p className="text-sm text-muted-foreground mb-10">{t("dash.createFirst")}</p>
+      <div className="flex flex-col items-center justify-center min-h-[60vh] text-center fade-in">
+        <div className="w-20 h-20 rounded-full bg-primary/5 flex items-center justify-center mb-8">
+          <BookOpen size={40} className="text-primary/20" />
+        </div>
+        <h2 className="font-serif text-3xl italic text-foreground/80 mb-3">{t("dash.noBooks")}</h2>
+        <p className="text-sm text-muted-foreground max-w-xs leading-relaxed mb-10">
+          {t("dash.createFirst")}
+        </p>
         <button
           onClick={nav.toBookCreate}
-          className="px-7 py-3 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
+          className="group flex items-center gap-2 px-8 py-3.5 rounded-xl text-sm font-bold bg-primary text-primary-foreground hover:scale-105 active:scale-95 transition-all shadow-lg shadow-primary/20"
         >
+          <Plus size={18} />
           {t("nav.newBook")}
         </button>
       </div>
     );
   }
 
-  /* ── Book list ── */
   return (
-    <div className="space-y-10">
-      <div className="flex items-baseline justify-between">
-        <h1 className="font-serif text-3xl">{t("dash.title")}</h1>
+    <div className="space-y-12">
+      <div className="flex items-end justify-between border-b border-border/40 pb-8">
+        <div>
+          <h1 className="font-serif text-4xl mb-2">{t("dash.title")}</h1>
+          <p className="text-sm text-muted-foreground">{t("dash.subtitle")}</p>
+        </div>
         <button
           onClick={nav.toBookCreate}
-          className="px-4 py-2.5 rounded-md text-sm font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity"
+          className="group flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-bold bg-primary text-primary-foreground hover:scale-105 active:scale-95 transition-all shadow-lg shadow-primary/20"
         >
+          <Plus size={16} />
           {t("nav.newBook")}
         </button>
       </div>
 
-      <div className="space-y-3">
-        {data.books.map((book) => {
+      <div className="grid gap-6">
+        {data.books.map((book, index) => {
           const isWriting = writingBooks.has(book.id);
+          const staggerClass = `stagger-${Math.min(index + 1, 5)}`;
           return (
             <div
               key={book.id}
-              className={`group border ${c.card} rounded-lg overflow-hidden`}
+              className={`paper-sheet group relative rounded-2xl overflow-hidden fade-in ${staggerClass}`}
             >
-              <div className="px-6 py-5 flex items-start justify-between">
-                <div className="min-w-0">
-                  <button
-                    onClick={() => nav.toBook(book.id)}
-                    className="font-serif text-lg hover:text-primary transition-colors text-left truncate block"
-                  >
-                    {book.title}
-                  </button>
-                  <div className="flex items-center gap-3 mt-2 text-sm text-muted-foreground">
-                    <span className="uppercase tracking-wider">{book.genre}</span>
-                    <span className="text-border">|</span>
-                    <span>{book.chaptersWritten} {t("dash.chapters")}</span>
-                    <span className="text-border">|</span>
-                    <span className={
-                      book.status === "active" ? "text-emerald-500" :
-                      book.status === "paused" ? "text-amber-500" :
-                      "text-muted-foreground"
-                    }>
-                      {book.status}
-                    </span>
-                    {book.language === "en" && <span className="text-primary/70">EN</span>}
-                    {book.fanficMode && <span className="text-purple-400">{book.fanficMode}</span>}
+              <div className="p-8 flex items-start justify-between">
+                <div className="flex-1 min-w-0">
+                  <div className="flex items-center gap-3 mb-3">
+                    <div className="p-2 rounded-lg bg-primary/5 text-primary">
+                      <BookOpen size={20} />
+                    </div>
+                    <button
+                      onClick={() => nav.toBook(book.id)}
+                      className="font-serif text-2xl hover:text-primary transition-all text-left truncate block font-medium hover:underline underline-offset-4 decoration-primary/30"
+                    >
+                      {book.title}
+                    </button>
+                  </div>
+                  
+                  <div className="flex flex-wrap items-center gap-y-2 gap-x-4 text-[13px] text-muted-foreground font-medium">
+                    <div className="flex items-center gap-1.5 px-2 py-0.5 rounded bg-secondary/50">
+                      <span className="uppercase tracking-wider">{book.genre}</span>
+                    </div>
+                    <div className="flex items-center gap-1.5">
+                      <Clock size={14} />
+                      <span>{book.chaptersWritten} {t("dash.chapters")}</span>
+                    </div>
+                    <div className="flex items-center gap-1.5">
+                      <div className={`w-2 h-2 rounded-full ${
+                        book.status === "active" ? "bg-emerald-500" :
+                        book.status === "paused" ? "bg-amber-500" :
+                        "bg-muted-foreground"
+                      }`} />
+                      <span>{
+                        book.status === "active" ? t("book.statusActive") :
+                        book.status === "paused" ? t("book.statusPaused") :
+                        book.status === "outlining" ? t("book.statusOutlining") :
+                        book.status === "completed" ? t("book.statusCompleted") :
+                        book.status === "dropped" ? t("book.statusDropped") :
+                        book.status
+                      }</span>
+                    </div>
+                    {book.language === "en" && (
+                      <span className="px-1.5 py-0.5 rounded border border-primary/20 text-primary text-[10px] font-bold">EN</span>
+                    )}
+                    {book.fanficMode && (
+                      <span className="flex items-center gap-1 text-purple-500">
+                        <Zap size={12} />
+                        <span className="italic">{book.fanficMode}</span>
+                      </span>
+                    )}
                   </div>
                 </div>
 
-                <div className="flex gap-2 shrink-0 ml-4">
+                <div className="flex items-center gap-3 shrink-0 ml-6">
                   <button
                     onClick={() => postApi(`/books/${book.id}/write-next`)}
                     disabled={isWriting}
-                    className={`px-4 py-2.5 text-sm rounded-md transition-all ${
+                    className={`flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-bold transition-all shadow-sm ${
                       isWriting
-                        ? "bg-primary/20 text-primary cursor-wait"
-                        : "bg-secondary text-secondary-foreground hover:bg-secondary/80"
+                        ? "bg-primary/20 text-primary cursor-wait animate-pulse"
+                        : "bg-secondary text-foreground hover:bg-primary hover:text-primary-foreground hover:shadow-lg hover:shadow-primary/20 hover:scale-105 active:scale-95"
                     }`}
                   >
-                    {isWriting ? t("dash.writing") : t("dash.writeNext")}
+                    {isWriting ? (
+                      <>
+                        <div className="w-4 h-4 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
+                        {t("dash.writing")}
+                      </>
+                    ) : (
+                      <>
+                        <Zap size={16} />
+                        {t("dash.writeNext")}
+                      </>
+                    )}
                   </button>
                   <button
                     onClick={() => nav.toAnalytics(book.id)}
-                    className="px-4 py-2.5 text-sm rounded-md bg-secondary text-secondary-foreground hover:bg-secondary/80 transition-colors"
+                    className="p-3 rounded-xl bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 hover:border-primary/30 hover:shadow-md hover:scale-105 active:scale-95 transition-all border border-border/50 shadow-sm"
+                    title={t("dash.stats")}
                   >
-                    {t("dash.stats")}
+                    <BarChart2 size={18} />
                   </button>
+                  <BookMenu
+                    bookId={book.id}
+                    bookTitle={book.title}
+                    nav={nav}
+                    t={t}
+                    onDelete={() => refetch()}
+                  />
                 </div>
               </div>
 
-              {/* Writing progress bar for active writes */}
+              {/* Enhanced progress indicator */}
               {isWriting && (
-                <div className="h-0.5 bg-gradient-to-r from-primary/0 via-primary to-primary/0 animate-pulse" />
+                <div className="absolute bottom-0 left-0 right-0 h-1 bg-secondary overflow-hidden">
+                   <div className="h-full bg-primary w-1/3 animate-[progress_2s_ease-in-out_infinite]" />
+                </div>
               )}
             </div>
           );
         })}
       </div>
 
-      {/* Live writing progress panel */}
+      {/* Modern writing progress panel */}
       {writingBooks.size > 0 && logEvents.length > 0 && (
-        <div className="border border-primary/30 bg-primary/5 rounded-lg p-6">
-          <h3 className="text-sm uppercase tracking-wide text-primary font-medium mb-4">{t("dash.writingProgress")}</h3>
-          <div className="space-y-1.5 text-sm font-mono text-muted-foreground">
+        <div className="glass-panel rounded-2xl p-8 border-primary/20 bg-primary/[0.02] shadow-2xl shadow-primary/5 fade-in">
+          <div className="flex items-center justify-between mb-6">
+            <div className="flex items-center gap-3">
+              <div className="p-2 rounded-lg bg-primary text-primary-foreground shadow-lg shadow-primary/20">
+                <Flame size={18} className="animate-pulse" />
+              </div>
+              <div>
+                <h3 className="text-sm font-bold uppercase tracking-widest text-primary"> Manuscript Foundry</h3>
+                <p className="text-xs text-muted-foreground mt-0.5">Real-time LLM generation tracking</p>
+              </div>
+            </div>
+            {progressEvent && (
+              <div className="flex items-center gap-4 text-xs font-bold text-primary px-4 py-2 rounded-full bg-primary/10 border border-primary/20">
+                <div className="flex items-center gap-2">
+                  <Clock size={12} />
+                  <span>{Math.round(((progressEvent.data as { elapsedMs?: number })?.elapsedMs ?? 0) / 1000)}s</span>
+                </div>
+                <div className="w-px h-3 bg-primary/20" />
+                <div className="flex items-center gap-2">
+                  <Zap size={12} />
+                  <span>{((progressEvent.data as { totalChars?: number })?.totalChars ?? 0).toLocaleString()} Chars</span>
+                </div>
+              </div>
+            )}
+          </div>
+          
+          <div className="space-y-2 font-mono text-xs bg-black/5 dark:bg-black/20 p-6 rounded-xl border border-border/50 max-h-[200px] overflow-y-auto scrollbar-thin">
             {logEvents.map((msg, i) => {
               const d = msg.data as { tag?: string; message?: string };
               return (
-                <div key={i} className="leading-relaxed">
-                  <span className="text-primary/50">{d.tag}</span>
-                  <span className="text-border mx-1.5">›</span>
-                  <span>{d.message}</span>
+                <div key={i} className="flex gap-3 leading-relaxed animate-in fade-in slide-in-from-left-2 duration-300">
+                  <span className="text-primary/60 font-bold shrink-0">[{d.tag}]</span>
+                  <span className="text-muted-foreground">{d.message}</span>
                 </div>
               );
             })}
-            {progressEvent && (
-              <div className="text-primary mt-3 pt-3 border-t border-primary/10">
-                streaming {Math.round(((progressEvent.data as { elapsedMs?: number })?.elapsedMs ?? 0) / 1000)}s
-                <span className="text-border mx-1.5">·</span>
-                {((progressEvent.data as { totalChars?: number })?.totalChars ?? 0).toLocaleString()} chars
-              </div>
-            )}
           </div>
         </div>
       )}
+
+      <style>{`
+        @keyframes progress {
+          0% { transform: translateX(-100%); }
+          100% { transform: translateX(300%); }
+        }
+      `}</style>
     </div>
   );
 }

+ 82 - 0
packages/studio/src/pages/DoctorView.tsx

@@ -0,0 +1,82 @@
+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";
+import { Stethoscope, CheckCircle2, XCircle, Loader2 } from "lucide-react";
+
+interface DoctorChecks {
+  readonly inkosJson: boolean;
+  readonly projectEnv: boolean;
+  readonly globalEnv: boolean;
+  readonly booksDir: boolean;
+  readonly llmConnected: boolean;
+  readonly bookCount: number;
+}
+
+interface Nav { toDashboard: () => void }
+
+function CheckRow({ label, ok, detail }: { label: string; ok: boolean; detail?: string }) {
+  return (
+    <div className="flex items-center gap-3 py-3 border-b border-border/30 last:border-0">
+      {ok ? (
+        <CheckCircle2 size={18} className="text-emerald-500 shrink-0" />
+      ) : (
+        <XCircle size={18} className="text-destructive shrink-0" />
+      )}
+      <span className="text-sm font-medium flex-1">{label}</span>
+      {detail && <span className="text-xs text-muted-foreground">{detail}</span>}
+    </div>
+  );
+}
+
+export function DoctorView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
+  const c = useColors(theme);
+  const { data, refetch } = useApi<DoctorChecks>("/doctor");
+
+  return (
+    <div className="space-y-8">
+      <div className="flex items-center gap-2 text-sm text-muted-foreground">
+        <button onClick={nav.toDashboard} className={c.link}>{t("bread.home")}</button>
+        <span className="text-border">/</span>
+        <span>{t("nav.doctor")}</span>
+      </div>
+
+      <div className="flex items-center justify-between">
+        <h1 className="font-serif text-3xl flex items-center gap-3">
+          <Stethoscope size={28} className="text-primary" />
+          {t("doctor.title")}
+        </h1>
+        <button onClick={() => refetch()} className={`px-4 py-2 text-sm rounded-lg ${c.btnSecondary}`}>
+          {t("doctor.recheck")}
+        </button>
+      </div>
+
+      {!data ? (
+        <div className="flex items-center justify-center py-12">
+          <Loader2 size={24} className="animate-spin text-primary" />
+        </div>
+      ) : (
+        <div className={`border ${c.cardStatic} rounded-lg p-5`}>
+          <CheckRow label={t("doctor.inkosJson")} ok={data.inkosJson} />
+          <CheckRow label={t("doctor.projectEnv")} ok={data.projectEnv} />
+          <CheckRow label={t("doctor.globalEnv")} ok={data.globalEnv} />
+          <CheckRow label={t("doctor.booksDir")} ok={data.booksDir} detail={`${data.bookCount} book(s)`} />
+          <CheckRow label={t("doctor.llmApi")} ok={data.llmConnected} detail={data.llmConnected ? t("doctor.connected") : t("doctor.failed")} />
+        </div>
+      )}
+
+      {data && (
+        <div className={`px-4 py-3 rounded-lg text-sm font-medium ${
+          data.inkosJson && (data.projectEnv || data.globalEnv) && data.llmConnected
+            ? "bg-emerald-500/10 text-emerald-600"
+            : "bg-amber-500/10 text-amber-600"
+        }`}>
+          {data.inkosJson && (data.projectEnv || data.globalEnv) && data.llmConnected
+            ? t("doctor.allPassed")
+            : t("doctor.someFailed")
+          }
+        </div>
+      )}
+    </div>
+  );
+}

+ 331 - 14
packages/studio/src/pages/GenreManager.tsx

@@ -1,8 +1,10 @@
-import { useApi, postApi } from "../hooks/use-api";
+import { fetchJson, 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 { useI18n } from "../hooks/use-i18n";
 import { useColors } from "../hooks/use-colors";
+import { Plus, Pencil, Trash2 } from "lucide-react";
 
 interface GenreInfo {
   readonly id: string;
@@ -20,21 +22,200 @@ interface GenreDetail {
     readonly fatigueWords: ReadonlyArray<string>;
     readonly numericalSystem: boolean;
     readonly powerScaling: boolean;
+    readonly eraResearch: boolean;
     readonly pacingRule: string;
     readonly auditDimensions: ReadonlyArray<number>;
   };
   readonly body: string;
 }
 
+interface GenreFormData {
+  readonly id: string;
+  readonly name: string;
+  readonly language: "zh" | "en";
+  readonly chapterTypes: string;
+  readonly fatigueWords: string;
+  readonly numericalSystem: boolean;
+  readonly powerScaling: boolean;
+  readonly eraResearch: boolean;
+  readonly pacingRule: string;
+  readonly body: string;
+}
+
+const EMPTY_FORM: GenreFormData = {
+  id: "",
+  name: "",
+  language: "zh",
+  chapterTypes: "",
+  fatigueWords: "",
+  numericalSystem: false,
+  powerScaling: false,
+  eraResearch: false,
+  pacingRule: "",
+  body: "",
+};
+
+function parseCommaSeparated(value: string): ReadonlyArray<string> {
+  return value.split(",").map((s) => s.trim()).filter(Boolean);
+}
+
+function GenreForm({
+  form,
+  onChange,
+  onSubmit,
+  onCancel,
+  isEdit,
+  c,
+  t,
+}: {
+  readonly form: GenreFormData;
+  readonly onChange: (next: GenreFormData) => void;
+  readonly onSubmit: () => void;
+  readonly onCancel: () => void;
+  readonly isEdit: boolean;
+  readonly c: ReturnType<typeof useColors>;
+  readonly t: TFunction;
+}) {
+  const set = <K extends keyof GenreFormData>(key: K, value: GenreFormData[K]) =>
+    onChange({ ...form, [key]: value });
+
+  return (
+    <div className="space-y-4">
+      <div className="grid grid-cols-2 gap-4">
+        <div>
+          <label className="text-xs text-muted-foreground uppercase tracking-wide">ID</label>
+          <input
+            type="text"
+            value={form.id}
+            onChange={(e) => set("id", e.target.value)}
+            disabled={isEdit}
+            className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm disabled:opacity-50"
+          />
+        </div>
+        <div>
+          <label className="text-xs text-muted-foreground uppercase tracking-wide">Name</label>
+          <input
+            type="text"
+            value={form.name}
+            onChange={(e) => set("name", e.target.value)}
+            className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
+          />
+        </div>
+      </div>
+
+      <div>
+        <label className="text-xs text-muted-foreground uppercase tracking-wide">Language</label>
+        <select
+          value={form.language}
+          onChange={(e) => set("language", e.target.value as "zh" | "en")}
+          className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
+        >
+          <option value="zh">zh</option>
+          <option value="en">en</option>
+        </select>
+      </div>
+
+      <div>
+        <label className="text-xs text-muted-foreground uppercase tracking-wide">
+          Chapter Types (comma-separated)
+        </label>
+        <input
+          type="text"
+          value={form.chapterTypes}
+          onChange={(e) => set("chapterTypes", e.target.value)}
+          className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
+        />
+      </div>
+
+      <div>
+        <label className="text-xs text-muted-foreground uppercase tracking-wide">
+          Fatigue Words (comma-separated)
+        </label>
+        <input
+          type="text"
+          value={form.fatigueWords}
+          onChange={(e) => set("fatigueWords", e.target.value)}
+          className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
+        />
+      </div>
+
+      <div className="flex gap-6">
+        <label className="flex items-center gap-2 text-sm">
+          <input
+            type="checkbox"
+            checked={form.numericalSystem}
+            onChange={(e) => set("numericalSystem", e.target.checked)}
+          />
+          Numerical System
+        </label>
+        <label className="flex items-center gap-2 text-sm">
+          <input
+            type="checkbox"
+            checked={form.powerScaling}
+            onChange={(e) => set("powerScaling", e.target.checked)}
+          />
+          Power Scaling
+        </label>
+        <label className="flex items-center gap-2 text-sm">
+          <input
+            type="checkbox"
+            checked={form.eraResearch}
+            onChange={(e) => set("eraResearch", e.target.checked)}
+          />
+          Era Research
+        </label>
+      </div>
+
+      <div>
+        <label className="text-xs text-muted-foreground uppercase tracking-wide">Pacing Rule</label>
+        <input
+          type="text"
+          value={form.pacingRule}
+          onChange={(e) => set("pacingRule", e.target.value)}
+          className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
+        />
+      </div>
+
+      <div>
+        <label className="text-xs text-muted-foreground uppercase tracking-wide">Rules (Markdown)</label>
+        <textarea
+          value={form.body}
+          onChange={(e) => set("body", e.target.value)}
+          rows={6}
+          className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm font-mono"
+        />
+      </div>
+
+      <div className="flex gap-2">
+        <button onClick={onSubmit} className={`px-4 py-2 text-sm rounded-md ${c.btnPrimary}`}>
+          {isEdit ? t("genre.saveChanges") : t("genre.createNew")}
+        </button>
+        <button onClick={onCancel} className={`px-4 py-2 text-sm rounded-md ${c.btnSecondary}`}>
+          {t("genre.cancel")}
+        </button>
+      </div>
+    </div>
+  );
+}
+
 interface Nav {
   toDashboard: () => void;
 }
 
 export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
   const c = useColors(theme);
+  const { lang } = useI18n();
   const { data, refetch } = useApi<{ genres: ReadonlyArray<GenreInfo> }>("/genres");
   const [selected, setSelected] = useState<string | null>(null);
-  const { data: detail } = useApi<GenreDetail>(selected ? `/genres/${selected}` : "");
+  const [formMode, setFormMode] = useState<"hidden" | "create" | "edit">("hidden");
+  const [form, setForm] = useState<GenreFormData>(EMPTY_FORM);
+
+  // Only show genres matching current language, plus custom project genres
+  const filteredGenres = data?.genres.filter((g) => g.language === lang || g.source === "project") ?? [];
+  const validSelected = selected && filteredGenres.some((g) => g.id === selected) ? selected : null;
+  const selectedGenre = filteredGenres.find((g) => g.id === validSelected) ?? null;
+
+  const { data: detail } = useApi<GenreDetail>(validSelected ? `/genres/${validSelected}` : "");
 
   const handleCopy = async (id: string) => {
     await postApi(`/genres/${id}/copy`);
@@ -42,6 +223,97 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
     refetch();
   };
 
+  const openCreateForm = () => {
+    setForm(EMPTY_FORM);
+    setFormMode("create");
+  };
+
+  const openEditForm = () => {
+    if (!detail) return;
+    setForm({
+      id: detail.profile.id,
+      name: detail.profile.name,
+      language: detail.profile.language as "zh" | "en",
+      chapterTypes: detail.profile.chapterTypes.join(", "),
+      fatigueWords: detail.profile.fatigueWords.join(", "),
+      numericalSystem: detail.profile.numericalSystem,
+      powerScaling: detail.profile.powerScaling,
+      eraResearch: detail.profile.eraResearch ?? false,
+      pacingRule: detail.profile.pacingRule,
+      body: detail.body,
+    });
+    setFormMode("edit");
+  };
+
+  const closeForm = () => {
+    setFormMode("hidden");
+  };
+
+  const handleCreate = async () => {
+    try {
+      await postApi("/genres/create", {
+        id: form.id,
+        name: form.name,
+        language: form.language,
+        chapterTypes: parseCommaSeparated(form.chapterTypes),
+        fatigueWords: parseCommaSeparated(form.fatigueWords),
+        numericalSystem: form.numericalSystem,
+        powerScaling: form.powerScaling,
+        eraResearch: form.eraResearch,
+        pacingRule: form.pacingRule,
+        body: form.body,
+      });
+      setFormMode("hidden");
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Failed to create genre");
+    }
+  };
+
+  const handleEdit = async () => {
+    if (!validSelected) return;
+    try {
+      await fetchJson(`/genres/${validSelected}`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          profile: {
+            id: form.id,
+            name: form.name,
+            language: form.language,
+            chapterTypes: parseCommaSeparated(form.chapterTypes),
+            fatigueWords: parseCommaSeparated(form.fatigueWords),
+            numericalSystem: form.numericalSystem,
+            powerScaling: form.powerScaling,
+            eraResearch: form.eraResearch,
+            pacingRule: form.pacingRule,
+          },
+          body: form.body,
+        }),
+      });
+      setFormMode("hidden");
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Failed to update genre");
+    }
+  };
+
+  const handleDelete = async () => {
+    if (!validSelected) return;
+    if (!window.confirm(`Delete genre "${validSelected}"?`)) return;
+    try {
+      const res = await fetch(`/api/genres/${validSelected}`, { method: "DELETE" });
+      if (!res.ok) {
+        const json = await res.json() as { error?: string };
+        throw new Error(json.error ?? `${res.status}`);
+      }
+      setSelected(null);
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Failed to delete genre");
+    }
+  };
+
   return (
     <div className="space-y-8">
       <div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -50,17 +322,43 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
         <span>{t("create.genre")}</span>
       </div>
 
-      <h1 className="font-serif text-3xl">{t("create.genre")}</h1>
+      <div className="flex items-center justify-between">
+        <h1 className="font-serif text-3xl">{t("create.genre")}</h1>
+        <button
+          onClick={openCreateForm}
+          className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md ${c.btnPrimary}`}
+        >
+          <Plus size={16} />
+          Create Genre
+        </button>
+      </div>
+
+      {formMode !== "hidden" && (
+        <div className={`border ${c.cardStatic} rounded-lg p-6`}>
+          <h2 className="text-lg font-medium mb-4">
+            {formMode === "create" ? "Create New Genre" : `Edit: ${form.id}`}
+          </h2>
+          <GenreForm
+            form={form}
+            onChange={setForm}
+            onSubmit={formMode === "create" ? handleCreate : handleEdit}
+            onCancel={closeForm}
+            isEdit={formMode === "edit"}
+            c={c}
+            t={t}
+          />
+        </div>
+      )}
 
       <div className="grid grid-cols-[250px_1fr] gap-6">
         {/* Genre list */}
         <div className={`border ${c.cardStatic} rounded-lg overflow-hidden`}>
-          {data?.genres.map((g) => (
+          {filteredGenres.map((g) => (
             <button
               key={g.id}
               onClick={() => setSelected(g.id)}
               className={`w-full text-left px-4 py-3 border-b border-border/40 transition-colors ${
-                selected === g.id ? "bg-primary/10 text-primary" : "hover:bg-muted/30"
+                validSelected === g.id ? "bg-primary/10 text-primary" : "hover:bg-muted/30"
               }`}
             >
               <div className="text-sm font-medium">{g.name}</div>
@@ -73,7 +371,7 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
 
         {/* Detail panel */}
         <div className={`border ${c.cardStatic} rounded-lg p-6 min-h-[400px]`}>
-          {selected && detail ? (
+          {validSelected && detail ? (
             <div className="space-y-6">
               <div className="flex items-start justify-between">
                 <div>
@@ -82,21 +380,40 @@ export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFu
                     {detail.profile.id} · {detail.profile.language} ·
                     {detail.profile.numericalSystem ? " Numerical" : ""}
                     {detail.profile.powerScaling ? " Power" : ""}
+                    {detail.profile.eraResearch ? " Era" : ""}
                   </div>
                 </div>
-                <button
-                  onClick={() => handleCopy(selected)}
-                  className={`px-3 py-1.5 text-sm ${c.btnSecondary} rounded-md`}
-                >
-                  Copy to Project
-                </button>
+                <div className="flex gap-2">
+                  <button
+                    onClick={openEditForm}
+                    className={`flex items-center gap-1.5 px-3 py-1.5 text-sm ${c.btnSecondary} rounded-md`}
+                  >
+                    <Pencil size={14} />
+                    Edit
+                  </button>
+                  {selectedGenre?.source === "project" && (
+                    <button
+                      onClick={handleDelete}
+                      className={`flex items-center gap-1.5 px-3 py-1.5 text-sm ${c.btnDanger} rounded-md`}
+                    >
+                      <Trash2 size={14} />
+                      Delete
+                    </button>
+                  )}
+                  <button
+                    onClick={() => validSelected && handleCopy(validSelected)}
+                    className={`px-3 py-1.5 text-sm ${c.btnSecondary} rounded-md`}
+                  >
+                    Copy to Project
+                  </button>
+                </div>
               </div>
 
               <div>
                 <div className="text-xs text-muted-foreground uppercase tracking-wide mb-2">Chapter Types</div>
                 <div className="flex gap-2 flex-wrap">
-                  {detail.profile.chapterTypes.map((t) => (
-                    <span key={t} className="px-2 py-1 text-xs bg-secondary rounded">{t}</span>
+                  {detail.profile.chapterTypes.map((ct) => (
+                    <span key={ct} className="px-2 py-1 text-xs bg-secondary rounded">{ct}</span>
                   ))}
                 </div>
               </div>

+ 216 - 0
packages/studio/src/pages/ImportManager.tsx

@@ -0,0 +1,216 @@
+import { useState } from "react";
+import { fetchJson, useApi, postApi } from "../hooks/use-api";
+import type { Theme } from "../hooks/use-theme";
+import type { TFunction } from "../hooks/use-i18n";
+import { useI18n } from "../hooks/use-i18n";
+import { useColors } from "../hooks/use-colors";
+import { FileInput, BookCopy, Feather } from "lucide-react";
+
+interface BookSummary {
+  readonly id: string;
+  readonly title: string;
+}
+
+interface Nav { toDashboard: () => void }
+
+type Tab = "chapters" | "canon" | "fanfic";
+
+export function ImportManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
+  const c = useColors(theme);
+  const { lang } = useI18n();
+  const { data: booksData } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
+  const [tab, setTab] = useState<Tab>("chapters");
+  const [status, setStatus] = useState("");
+  const [loading, setLoading] = useState(false);
+
+  // Chapters state
+  const [chText, setChText] = useState("");
+  const [chBookId, setChBookId] = useState("");
+  const [chSplitRegex, setChSplitRegex] = useState("");
+
+  // Canon state
+  const [canonTarget, setCanonTarget] = useState("");
+  const [canonFrom, setCanonFrom] = useState("");
+
+  // Fanfic state
+  const [ffTitle, setFfTitle] = useState("");
+  const [ffText, setFfText] = useState("");
+  const [ffMode, setFfMode] = useState("canon");
+  const [ffGenre, setFfGenre] = useState("other");
+  const [ffLang, setFfLang] = useState(lang);
+
+  const handleImportChapters = async () => {
+    if (!chText.trim() || !chBookId) return;
+    setLoading(true);
+    setStatus("");
+    try {
+      const data = await fetchJson<{ importedCount?: number }>(`/books/${chBookId}/import/chapters`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ text: chText, splitRegex: chSplitRegex || undefined }),
+      });
+      setStatus(`Imported ${data.importedCount} chapters`);
+    } catch (e) {
+      setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`);
+    }
+    setLoading(false);
+  };
+
+  const handleImportCanon = async () => {
+    if (!canonTarget || !canonFrom) return;
+    setLoading(true);
+    setStatus("");
+    try {
+      await postApi(`/books/${canonTarget}/import/canon`, { fromBookId: canonFrom });
+      setStatus("Canon imported successfully!");
+    } catch (e) {
+      setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`);
+    }
+    setLoading(false);
+  };
+
+  const handleFanficInit = async () => {
+    if (!ffTitle.trim() || !ffText.trim()) return;
+    setLoading(true);
+    setStatus("");
+    try {
+      const data = await fetchJson<{ bookId?: string }>("/fanfic/init", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          title: ffTitle, sourceText: ffText, mode: ffMode,
+          genre: ffGenre, language: ffLang,
+        }),
+      });
+      setStatus(`Fanfic created: ${data.bookId}`);
+    } catch (e) {
+      setStatus(`Error: ${e instanceof Error ? e.message : String(e)}`);
+    }
+    setLoading(false);
+  };
+
+  const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
+    { id: "chapters", label: t("import.chapters"), icon: <FileInput size={14} /> },
+    { id: "canon", label: t("import.canon"), icon: <BookCopy size={14} /> },
+    { id: "fanfic", label: t("import.fanfic"), icon: <Feather size={14} /> },
+  ];
+
+  return (
+    <div className="space-y-8">
+      <div className="flex items-center gap-2 text-sm text-muted-foreground">
+        <button onClick={nav.toDashboard} className={c.link}>{t("bread.home")}</button>
+        <span className="text-border">/</span>
+        <span>{t("nav.import")}</span>
+      </div>
+
+      <h1 className="font-serif text-3xl flex items-center gap-3">
+        <FileInput size={28} className="text-primary" />
+        {t("import.title")}
+      </h1>
+
+      {/* Tabs */}
+      <div className="flex gap-1 bg-secondary/30 rounded-lg p-1 w-fit">
+        {tabs.map((tb) => (
+          <button
+            key={tb.id}
+            onClick={() => { setTab(tb.id); setStatus(""); }}
+            className={`px-4 py-2 rounded-md text-sm font-medium flex items-center gap-2 transition-all ${
+              tab === tb.id ? "bg-card shadow-sm text-foreground" : "text-muted-foreground hover:text-foreground"
+            }`}
+          >
+            {tb.icon} {tb.label}
+          </button>
+        ))}
+      </div>
+
+      {/* Tab content */}
+      <div className={`border ${c.cardStatic} rounded-lg p-6 space-y-4`}>
+        {tab === "chapters" && (
+          <>
+            <select value={chBookId} onChange={(e) => setChBookId(e.target.value)}
+              className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm">
+              <option value="">{t("import.selectTarget")}</option>
+              {booksData?.books.map((b) => <option key={b.id} value={b.id}>{b.title}</option>)}
+            </select>
+            <input
+              type="text" value={chSplitRegex} onChange={(e) => setChSplitRegex(e.target.value)}
+              placeholder={t("import.splitRegex")}
+              className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm font-mono"
+            />
+            <textarea value={chText} onChange={(e) => setChText(e.target.value)} rows={10}
+              placeholder={t("import.pasteChapters")}
+              className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm resize-none font-mono"
+            />
+            <button onClick={handleImportChapters} disabled={loading || !chBookId || !chText.trim()}
+              className={`px-4 py-2 text-sm rounded-lg ${c.btnPrimary} disabled:opacity-30`}>
+              {loading ? t("import.importing") : t("import.chapters")}
+            </button>
+          </>
+        )}
+
+        {tab === "canon" && (
+          <>
+            <select value={canonFrom} onChange={(e) => setCanonFrom(e.target.value)}
+              className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm">
+              <option value="">{t("import.selectSource")}</option>
+              {booksData?.books.map((b) => <option key={b.id} value={b.id}>{b.title}</option>)}
+            </select>
+            <select value={canonTarget} onChange={(e) => setCanonTarget(e.target.value)}
+              className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm">
+              <option value="">{t("import.selectDerivative")}</option>
+              {booksData?.books.map((b) => <option key={b.id} value={b.id}>{b.title}</option>)}
+            </select>
+            <button onClick={handleImportCanon} disabled={loading || !canonTarget || !canonFrom}
+              className={`px-4 py-2 text-sm rounded-lg ${c.btnPrimary} disabled:opacity-30`}>
+              {loading ? t("import.importing") : t("import.canon")}
+            </button>
+          </>
+        )}
+
+        {tab === "fanfic" && (
+          <>
+            <input type="text" value={ffTitle} onChange={(e) => setFfTitle(e.target.value)}
+              placeholder={t("import.fanficTitle")}
+              className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm"
+            />
+            <div className="grid grid-cols-3 gap-3">
+              <select value={ffMode} onChange={(e) => setFfMode(e.target.value)}
+                className="px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm">
+                <option value="canon">Canon</option>
+                <option value="au">AU</option>
+                <option value="ooc">OOC</option>
+                <option value="cp">CP</option>
+              </select>
+              <select value={ffGenre} onChange={(e) => setFfGenre(e.target.value)}
+                className="px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm">
+                <option value="other">Other</option>
+                <option value="xuanhuan">玄幻</option>
+                <option value="urban">都市</option>
+                <option value="xianxia">仙侠</option>
+              </select>
+              <select value={ffLang} onChange={(e) => setFfLang(e.target.value as "zh" | "en")}
+                className="px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm">
+                <option value="zh">中文</option>
+                <option value="en">English</option>
+              </select>
+            </div>
+            <textarea value={ffText} onChange={(e) => setFfText(e.target.value)} rows={10}
+              placeholder={t("import.pasteMaterial")}
+              className="w-full px-3 py-2 rounded-lg bg-secondary/30 border border-border text-sm resize-none font-mono"
+            />
+            <button onClick={handleFanficInit} disabled={loading || !ffTitle.trim() || !ffText.trim()}
+              className={`px-4 py-2 text-sm rounded-lg ${c.btnPrimary} disabled:opacity-30`}>
+              {loading ? t("import.creating") : t("import.fanfic")}
+            </button>
+          </>
+        )}
+
+        {status && (
+          <div className={`text-sm px-3 py-2 rounded-lg ${status.startsWith("Error") ? "bg-destructive/10 text-destructive" : "bg-emerald-500/10 text-emerald-600"}`}>
+            {status}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 114 - 0
packages/studio/src/pages/RadarView.tsx

@@ -0,0 +1,114 @@
+import { useState } from "react";
+import type { Theme } from "../hooks/use-theme";
+import type { TFunction } from "../hooks/use-i18n";
+import { useColors } from "../hooks/use-colors";
+import { fetchJson } from "../hooks/use-api";
+import { TrendingUp, Loader2, Target } from "lucide-react";
+
+interface Recommendation {
+  readonly confidence: number;
+  readonly platform: string;
+  readonly genre: string;
+  readonly concept: string;
+  readonly reasoning: string;
+  readonly benchmarkTitles: ReadonlyArray<string>;
+}
+
+interface RadarResult {
+  readonly marketSummary: string;
+  readonly recommendations: ReadonlyArray<Recommendation>;
+}
+
+interface Nav { toDashboard: () => void }
+
+export function RadarView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
+  const c = useColors(theme);
+  const [result, setResult] = useState<RadarResult | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState("");
+
+  const handleScan = async () => {
+    setLoading(true);
+    setError("");
+    setResult(null);
+    try {
+      const data = await fetchJson<RadarResult>("/radar/scan", { method: "POST" });
+      setResult(data);
+    } catch (e) {
+      setError(e instanceof Error ? e.message : String(e));
+    }
+    setLoading(false);
+  };
+
+  return (
+    <div className="space-y-8">
+      <div className="flex items-center gap-2 text-sm text-muted-foreground">
+        <button onClick={nav.toDashboard} className={c.link}>{t("bread.home")}</button>
+        <span className="text-border">/</span>
+        <span>{t("nav.radar")}</span>
+      </div>
+
+      <div className="flex items-center justify-between">
+        <h1 className="font-serif text-3xl flex items-center gap-3">
+          <TrendingUp size={28} className="text-primary" />
+          {t("radar.title")}
+        </h1>
+        <button
+          onClick={handleScan}
+          disabled={loading}
+          className={`px-5 py-2.5 text-sm rounded-lg ${c.btnPrimary} disabled:opacity-30 flex items-center gap-2`}
+        >
+          {loading ? <Loader2 size={14} className="animate-spin" /> : <Target size={14} />}
+          {loading ? t("radar.scanning") : t("radar.scan")}
+        </button>
+      </div>
+
+      {error && (
+        <div className="bg-destructive/10 text-destructive px-4 py-3 rounded-lg text-sm">{error}</div>
+      )}
+
+      {result && (
+        <div className="space-y-6">
+          <div className={`border ${c.cardStatic} rounded-lg p-5`}>
+            <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-3">{t("radar.summary")}</h3>
+            <p className="text-sm leading-relaxed whitespace-pre-wrap">{result.marketSummary}</p>
+          </div>
+
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+            {result.recommendations.map((rec, i) => (
+              <div key={i} className={`border ${c.cardStatic} rounded-lg p-5 space-y-3`}>
+                <div className="flex items-center justify-between">
+                  <span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
+                    {rec.platform} · {rec.genre}
+                  </span>
+                  <span className={`text-xs font-bold px-2 py-0.5 rounded-full ${
+                    rec.confidence >= 0.7 ? "bg-emerald-500/10 text-emerald-600" :
+                    rec.confidence >= 0.4 ? "bg-amber-500/10 text-amber-600" :
+                    "bg-muted text-muted-foreground"
+                  }`}>
+                    {(rec.confidence * 100).toFixed(0)}%
+                  </span>
+                </div>
+                <p className="text-sm font-semibold">{rec.concept}</p>
+                <p className="text-xs text-muted-foreground leading-relaxed">{rec.reasoning}</p>
+                {rec.benchmarkTitles.length > 0 && (
+                  <div className="flex gap-2 flex-wrap">
+                    {rec.benchmarkTitles.map((bt) => (
+                      <span key={bt} className="px-2 py-0.5 text-[10px] bg-secondary rounded">{bt}</span>
+                    ))}
+                  </div>
+                )}
+              </div>
+            ))}
+          </div>
+        </div>
+      )}
+
+      {!result && !loading && !error && (
+        <div className={`border border-dashed ${c.cardStatic} rounded-lg p-12 text-center text-muted-foreground text-sm italic`}>
+          {t("radar.emptyHint")}
+        </div>
+      )}
+    </div>
+  );
+}

+ 76 - 6
packages/studio/src/pages/TruthFiles.tsx

@@ -1,8 +1,9 @@
-import { useApi } from "../hooks/use-api";
+import { fetchJson, useApi } 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";
+import { Pencil, Save, X } from "lucide-react";
 
 interface TruthFile {
   readonly name: string;
@@ -19,10 +20,40 @@ export function TruthFiles({ bookId, nav, theme, t }: { bookId: string; nav: Nav
   const c = useColors(theme);
   const { data } = useApi<{ files: ReadonlyArray<TruthFile> }>(`/books/${bookId}/truth`);
   const [selected, setSelected] = useState<string | null>(null);
-  const { data: fileData } = useApi<{ file: string; content: string | null }>(
+  const [editMode, setEditMode] = useState(false);
+  const [editText, setEditText] = useState("");
+  const [savingEdit, setSavingEdit] = useState(false);
+  const { data: fileData, refetch: refetchFile } = useApi<{ file: string; content: string | null }>(
     selected ? `/books/${bookId}/truth/${selected}` : "",
   );
 
+  const startEdit = () => {
+    setEditText(fileData?.content ?? "");
+    setEditMode(true);
+  };
+
+  const cancelEdit = () => {
+    setEditMode(false);
+  };
+
+  const handleSaveEdit = async () => {
+    if (!selected) return;
+    setSavingEdit(true);
+    try {
+      await fetchJson(`/books/${bookId}/truth/${selected}`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ content: editText }),
+      });
+      setEditMode(false);
+      refetchFile();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Failed to save");
+    } finally {
+      setSavingEdit(false);
+    }
+  };
+
   return (
     <div className="space-y-6">
       <div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -41,7 +72,7 @@ export function TruthFiles({ bookId, nav, theme, t }: { bookId: string; nav: Nav
           {data?.files.map((f) => (
             <button
               key={f.name}
-              onClick={() => setSelected(f.name)}
+              onClick={() => { setSelected(f.name); setEditMode(false); }}
               className={`w-full text-left px-3 py-2.5 text-sm border-b border-border/40 transition-colors ${
                 selected === f.name
                   ? "bg-primary/10 text-primary"
@@ -58,9 +89,48 @@ export function TruthFiles({ bookId, nav, theme, t }: { bookId: string; nav: Nav
         </div>
 
         {/* Content viewer */}
-        <div className={`border ${c.cardStatic} rounded-lg p-5 min-h-[400px]`}>
-          {selected && fileData?.content ? (
-            <pre className="text-sm leading-relaxed whitespace-pre-wrap font-mono text-foreground/80">{fileData.content}</pre>
+        <div className={`border ${c.cardStatic} rounded-lg p-5 min-h-[400px] flex flex-col`}>
+          {selected && fileData?.content != null ? (
+            <>
+              <div className="flex items-center justify-end gap-2 mb-3">
+                {editMode ? (
+                  <>
+                    <button
+                      onClick={cancelEdit}
+                      className={`inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded-md ${c.btnSecondary}`}
+                    >
+                      <X size={14} />
+                      Cancel
+                    </button>
+                    <button
+                      onClick={handleSaveEdit}
+                      disabled={savingEdit}
+                      className={`inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded-md ${c.btnPrimary} disabled:opacity-50`}
+                    >
+                      <Save size={14} />
+                      {savingEdit ? t("truth.saving") : t("truth.save")}
+                    </button>
+                  </>
+                ) : (
+                  <button
+                    onClick={startEdit}
+                    className={`inline-flex items-center gap-1 px-3 py-1.5 text-xs rounded-md ${c.btnSecondary}`}
+                  >
+                    <Pencil size={14} />
+                    Edit
+                  </button>
+                )}
+              </div>
+              {editMode ? (
+                <textarea
+                  value={editText}
+                  onChange={(e) => setEditText(e.target.value)}
+                  className={`${c.input} flex-1 rounded-md p-3 text-sm font-mono leading-relaxed resize-none min-h-[360px]`}
+                />
+              ) : (
+                <pre className="text-sm leading-relaxed whitespace-pre-wrap font-mono text-foreground/80">{fileData.content}</pre>
+              )}
+            </>
           ) : selected && fileData?.content === null ? (
             <div className="text-muted-foreground text-sm">File not found</div>
           ) : (

+ 143 - 0
packages/studio/src/shared/contracts.ts

@@ -0,0 +1,143 @@
+/**
+ * Shared TypeScript contracts for Studio API/UI communication.
+ * Ported from PR #96 (Te9ui1a) — prevents client/server type drift.
+ */
+
+// --- Health ---
+
+export interface HealthStatus {
+  readonly status: "ok";
+  readonly projectRoot: string;
+  readonly projectConfigFound: boolean;
+  readonly envFound: boolean;
+  readonly projectEnvFound: boolean;
+  readonly globalConfigFound: boolean;
+  readonly bookCount: number;
+  readonly provider: string | null;
+  readonly model: string | null;
+}
+
+// --- Books ---
+
+export interface BookSummary {
+  readonly id: string;
+  readonly title: string;
+  readonly status: string;
+  readonly platform: string;
+  readonly genre: string;
+  readonly targetChapters: number;
+  readonly chapters: number;
+  readonly chapterCount: number;
+  readonly lastChapterNumber: number;
+  readonly totalWords: number;
+  readonly approvedChapters: number;
+  readonly pendingReview: number;
+  readonly pendingReviewChapters: number;
+  readonly failedReview: number;
+  readonly failedChapters: number;
+  readonly recentRunStatus?: string | null;
+  readonly updatedAt: string;
+}
+
+export interface BookDetail extends BookSummary {
+  readonly createdAt: string;
+  readonly chapterWordCount: number;
+  readonly language: "zh" | "en" | null;
+}
+
+// --- Chapters ---
+
+export interface ChapterSummary {
+  readonly number: number;
+  readonly title: string;
+  readonly status: string;
+  readonly wordCount: number;
+  readonly auditIssueCount: number;
+  readonly updatedAt: string;
+  readonly fileName: string | null;
+}
+
+export interface ChapterDetail extends ChapterSummary {
+  readonly auditIssues: ReadonlyArray<string>;
+  readonly reviewNote?: string;
+  readonly content: string;
+}
+
+export interface SaveChapterPayload {
+  readonly content: string;
+}
+
+// --- Truth Files ---
+
+export interface TruthFileSummary {
+  readonly name: string;
+  readonly label: string;
+  readonly exists: boolean;
+  readonly path: string;
+  readonly optional: boolean;
+  readonly available: boolean;
+}
+
+export interface TruthFileDetail extends TruthFileSummary {
+  readonly content: string | null;
+}
+
+// --- Review ---
+
+export interface ReviewActionPayload {
+  readonly chapterNumber: number;
+  readonly reason?: string;
+}
+
+// --- Runs ---
+
+export type RunAction = "draft" | "audit" | "revise" | "write-next";
+
+export type RunStatus = "queued" | "running" | "succeeded" | "failed";
+
+export interface RunLogEntry {
+  readonly timestamp: string;
+  readonly level: "info" | "warn" | "error";
+  readonly message: string;
+}
+
+export interface RunActionPayload {
+  readonly chapterNumber?: number;
+}
+
+export interface StudioRun {
+  readonly id: string;
+  readonly bookId: string;
+  readonly chapter: number | null;
+  readonly chapterNumber: number | null;
+  readonly action: RunAction;
+  readonly status: RunStatus;
+  readonly stage: string;
+  readonly createdAt: string;
+  readonly updatedAt: string;
+  readonly startedAt: string | null;
+  readonly finishedAt: string | null;
+  readonly logs: ReadonlyArray<RunLogEntry>;
+  readonly result?: unknown;
+  readonly error?: string;
+}
+
+export interface RunStreamEvent {
+  readonly type: "snapshot" | "status" | "stage" | "log";
+  readonly runId: string;
+  readonly run?: StudioRun;
+  readonly status?: RunStatus;
+  readonly stage?: string;
+  readonly log?: RunLogEntry;
+  readonly result?: unknown;
+  readonly error?: string;
+}
+
+// --- API Error Response ---
+
+export interface ApiErrorResponse {
+  readonly error: {
+    readonly code: string;
+    readonly message: string;
+  };
+}

+ 7 - 0
packages/studio/vitest.config.ts

@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+  test: {
+    include: ["src/**/*.test.ts"],
+  },
+});

+ 3 - 0
pnpm-lock.yaml

@@ -133,6 +133,9 @@ importers:
       vite:
         specifier: ^6.0.0
         version: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)
+      vitest:
+        specifier: ^3.0.0
+        version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(msw@2.12.13(@types/node@22.19.15)(typescript@5.9.3))
 
 packages:
 

+ 4 - 2
test-project/inkos.json

@@ -4,7 +4,8 @@
   "llm": {
     "provider": "openai",
     "baseUrl": "https://api.openai.com/v1",
-    "model": "gpt-4o"
+    "model": "gpt-4o",
+    "stream": false
   },
   "notify": [],
   "daemon": {
@@ -13,5 +14,6 @@
       "writeCron": "*/15 * * * *"
     },
     "maxConcurrentBooks": 3
-  }
+  },
+  "language": "zh"
 }