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

feat(studio): Phase 2+3 — audit/revise/detect/export/genres/model-routing/notify APIs + UI

New API endpoints:
- POST /api/books/:id/audit/:chapter — trigger chapter audit
- POST /api/books/:id/revise/:chapter — trigger chapter revision
- GET /api/books/:id/export?format=txt|md — download book
- POST /api/books/:id/detect/:chapter — AI-tell detection
- GET /api/genres/:id — genre detail (profile + rules body)
- POST /api/genres/:id/copy — copy builtin to project
- GET/PUT /api/project/model-overrides — multi-model routing
- GET/PUT /api/project/notify — notification channel management
- PUT /api/books/:id/truth/:file — edit truth files

New pages:
- GenreManager: split-pane genre browser with detail view,
  chapter types, fatigue words, pacing rules, copy to project

BookDetail enhancements:
- Per-chapter Audit/Revise/Detect buttons
- Export download link
- All chapter actions in unified row

Sidebar: added Genres nav item
Ma 7 месяцев назад
Родитель
Сommit
e099d71bf9

+ 5 - 1
packages/studio/src/App.tsx

@@ -10,6 +10,7 @@ import { ConfigView } from "./pages/ConfigView";
 import { TruthFiles } from "./pages/TruthFiles";
 import { DaemonControl } from "./pages/DaemonControl";
 import { LogViewer } from "./pages/LogViewer";
+import { GenreManager } from "./pages/GenreManager";
 import { LanguageSelector } from "./pages/LanguageSelector";
 import { useSSE } from "./hooks/use-sse";
 import { useTheme } from "./hooks/use-theme";
@@ -25,7 +26,8 @@ type Route =
   | { page: "config" }
   | { page: "truth"; bookId: string }
   | { page: "daemon" }
-  | { page: "logs" };
+  | { page: "logs" }
+  | { page: "genres" };
 
 export function App() {
   const [route, setRoute] = useState<Route>({ page: "dashboard" });
@@ -63,6 +65,7 @@ export function App() {
     toTruth: (bookId: string) => setRoute({ page: "truth", bookId }),
     toDaemon: () => setRoute({ page: "daemon" }),
     toLogs: () => setRoute({ page: "logs" }),
+    toGenres: () => setRoute({ page: "genres" }),
   };
 
   const activePage =
@@ -122,6 +125,7 @@ export function App() {
             {route.page === "truth" && <TruthFiles bookId={route.bookId} nav={nav} theme={theme} t={t} />}
             {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} />}
           </div>
         </div>
 

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

@@ -484,6 +484,226 @@ export function createStudioServer(config: ProjectConfig, root: string) {
     }
   });
 
+  // --- Audit ---
+
+  app.post("/api/books/:id/audit/:chapter", async (c) => {
+    const id = c.req.param("id");
+    const chapterNum = parseInt(c.req.param("chapter"), 10);
+    const bookDir = state.bookDir(id);
+
+    broadcast("audit:start", { bookId: id, chapter: chapterNum });
+    try {
+      const book = await state.loadBookConfig(id);
+      const chaptersDir = join(bookDir, "chapters");
+      const files = await readdir(chaptersDir);
+      const paddedNum = String(chapterNum).padStart(4, "0");
+      const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
+      if (!match) return c.json({ error: "Chapter not found" }, 404);
+
+      const content = await readFile(join(chaptersDir, match), "utf-8");
+      const { ContinuityAuditor } = await import("@actalk/inkos-core");
+      const auditor = new ContinuityAuditor({
+        client: createLLMClient(config.llm),
+        model: config.llm.model,
+        projectRoot: root,
+        bookId: id,
+      });
+      const result = await auditor.auditChapter(bookDir, content, chapterNum, book.genre);
+      broadcast("audit:complete", { bookId: id, chapter: chapterNum, passed: result.passed });
+      return c.json(result);
+    } catch (e) {
+      broadcast("audit:error", { bookId: id, error: String(e) });
+      return c.json({ error: String(e) }, 500);
+    }
+  });
+
+  // --- Revise ---
+
+  app.post("/api/books/:id/revise/:chapter", async (c) => {
+    const id = c.req.param("id");
+    const chapterNum = parseInt(c.req.param("chapter"), 10);
+    const bookDir = state.bookDir(id);
+    const body = await c.req.json<{ mode?: string }>().catch(() => ({ mode: "spot-fix" }));
+
+    broadcast("revise:start", { bookId: id, chapter: chapterNum });
+    try {
+      const book = await state.loadBookConfig(id);
+      const chaptersDir = join(bookDir, "chapters");
+      const files = await readdir(chaptersDir);
+      const paddedNum = String(chapterNum).padStart(4, "0");
+      const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
+      if (!match) return c.json({ error: "Chapter not found" }, 404);
+
+      const content = await readFile(join(chaptersDir, match), "utf-8");
+
+      // Get audit issues first
+      const index = await state.loadChapterIndex(id);
+      const chapterMeta = index.find((ch) => ch.number === chapterNum);
+      const issues = (chapterMeta?.auditIssues ?? []).map((desc) => ({
+        severity: "warning" as const,
+        category: "general",
+        description: desc,
+        suggestion: "",
+      }));
+
+      const { ReviserAgent } = await import("@actalk/inkos-core");
+      const reviser = new ReviserAgent({
+        client: createLLMClient(config.llm),
+        model: config.llm.model,
+        projectRoot: root,
+        bookId: id,
+      });
+      const result = await reviser.reviseChapter(
+        bookDir, content, chapterNum, issues,
+        (body.mode ?? "spot-fix") as "spot-fix",
+        book.genre,
+      );
+      broadcast("revise:complete", { bookId: id, chapter: chapterNum });
+      return c.json(result);
+    } catch (e) {
+      broadcast("revise:error", { bookId: id, error: String(e) });
+      return c.json({ error: String(e) }, 500);
+    }
+  });
+
+  // --- Export ---
+
+  app.get("/api/books/:id/export", async (c) => {
+    const id = c.req.param("id");
+    const format = (c.req.query("format") ?? "txt") as string;
+    const bookDir = state.bookDir(id);
+    const chaptersDir = join(bookDir, "chapters");
+
+    try {
+      const files = await readdir(chaptersDir);
+      const mdFiles = files.filter((f) => f.endsWith(".md") && !f.startsWith("index")).sort();
+      const contents = await Promise.all(
+        mdFiles.map((f) => readFile(join(chaptersDir, f), "utf-8")),
+      );
+
+      if (format === "md") {
+        const body = contents.join("\n\n---\n\n");
+        return new Response(body, {
+          headers: {
+            "Content-Type": "text/markdown; charset=utf-8",
+            "Content-Disposition": `attachment; filename="${id}.md"`,
+          },
+        });
+      }
+      // Default: txt
+      const body = contents.join("\n\n");
+      return new Response(body, {
+        headers: {
+          "Content-Type": "text/plain; charset=utf-8",
+          "Content-Disposition": `attachment; filename="${id}.txt"`,
+        },
+      });
+    } catch {
+      return c.json({ error: "Export failed" }, 500);
+    }
+  });
+
+  // --- Genre detail + copy ---
+
+  app.get("/api/genres/:id", async (c) => {
+    const genreId = c.req.param("id");
+    try {
+      const { readGenreProfile } = await import("@actalk/inkos-core");
+      const { profile, body } = await readGenreProfile(root, genreId);
+      return c.json({ profile, body });
+    } catch (e) {
+      return c.json({ error: String(e) }, 404);
+    }
+  });
+
+  app.post("/api/genres/:id/copy", async (c) => {
+    const genreId = c.req.param("id");
+    try {
+      const { getBuiltinGenresDir } = await import("@actalk/inkos-core");
+      const { mkdir: mkdirFs, copyFile } = await import("node:fs/promises");
+      const builtinDir = getBuiltinGenresDir();
+      const projectGenresDir = join(root, "genres");
+      await mkdirFs(projectGenresDir, { recursive: true });
+      await copyFile(join(builtinDir, `${genreId}.md`), join(projectGenresDir, `${genreId}.md`));
+      return c.json({ ok: true, path: `genres/${genreId}.md` });
+    } catch (e) {
+      return c.json({ error: String(e) }, 500);
+    }
+  });
+
+  // --- Model overrides ---
+
+  app.get("/api/project/model-overrides", async (c) => {
+    const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
+    return c.json({ overrides: raw.modelOverrides ?? {} });
+  });
+
+  app.put("/api/project/model-overrides", async (c) => {
+    const { overrides } = await c.req.json<{ overrides: Record<string, unknown> }>();
+    const configPath = join(root, "inkos.json");
+    const raw = JSON.parse(await readFile(configPath, "utf-8"));
+    raw.modelOverrides = overrides;
+    const { writeFile: writeFileFs } = await import("node:fs/promises");
+    await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
+    return c.json({ ok: true });
+  });
+
+  // --- Notify channels ---
+
+  app.get("/api/project/notify", async (c) => {
+    const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
+    return c.json({ channels: raw.notify ?? [] });
+  });
+
+  app.put("/api/project/notify", async (c) => {
+    const { channels } = await c.req.json<{ channels: unknown[] }>();
+    const configPath = join(root, "inkos.json");
+    const raw = JSON.parse(await readFile(configPath, "utf-8"));
+    raw.notify = channels;
+    const { writeFile: writeFileFs } = await import("node:fs/promises");
+    await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
+    return c.json({ ok: true });
+  });
+
+  // --- AIGC Detection ---
+
+  app.post("/api/books/:id/detect/:chapter", async (c) => {
+    const id = c.req.param("id");
+    const chapterNum = parseInt(c.req.param("chapter"), 10);
+    const bookDir = state.bookDir(id);
+
+    try {
+      const chaptersDir = join(bookDir, "chapters");
+      const files = await readdir(chaptersDir);
+      const paddedNum = String(chapterNum).padStart(4, "0");
+      const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
+      if (!match) return c.json({ error: "Chapter not found" }, 404);
+
+      const content = await readFile(join(chaptersDir, match), "utf-8");
+      const { analyzeAITells } = await import("@actalk/inkos-core");
+      const result = analyzeAITells(content);
+      return c.json({ chapterNumber: chapterNum, ...result });
+    } catch (e) {
+      return c.json({ error: String(e) }, 500);
+    }
+  });
+
+  // --- Truth file edit ---
+
+  app.put("/api/books/:id/truth/:file", async (c) => {
+    const id = c.req.param("id");
+    const file = c.req.param("file");
+    if (!TRUTH_FILES.includes(file)) {
+      return c.json({ error: "Invalid truth file" }, 400);
+    }
+    const { content } = await c.req.json<{ content: string }>();
+    const bookDir = state.bookDir(id);
+    const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
+    await mkdirFs(join(bookDir, "story"), { recursive: true });
+    await writeFileFs(join(bookDir, "story", file), content, "utf-8");
+    return c.json({ ok: true });
+  });
+
   return app;
 }
 

+ 7 - 0
packages/studio/src/components/Sidebar.tsx

@@ -16,6 +16,7 @@ interface Nav {
   toConfig: () => void;
   toDaemon: () => void;
   toLogs: () => void;
+  toGenres: () => void;
 }
 
 export function Sidebar({ nav, activePage, t }: {
@@ -73,6 +74,12 @@ export function Sidebar({ nav, activePage, t }: {
 
       {/* 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="⚙"

+ 60 - 16
packages/studio/src/pages/BookDetail.tsx

@@ -141,6 +141,13 @@ export function BookDetail({ bookId, nav, theme, t }: { bookId: string; nav: Nav
           >
             {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`}
+          >
+            Export
+          </a>
         </div>
       </div>
 
@@ -172,22 +179,59 @@ export function BookDetail({ bookId, nav, theme, t }: { bookId: string; nav: Nav
                   {ch.status}
                 </td>
                 <td className="px-4 py-3 text-right">
-                  {ch.status === "ready-for-review" && (
-                    <div className="flex gap-1 justify-end">
-                      <button
-                        onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/approve`); refetch(); }}
-                        className={`px-2 py-1 text-xs ${c.btnSuccess} rounded`}
-                      >
-                        {t("book.approve")}
-                      </button>
-                      <button
-                        onClick={async () => { await postApi(`/books/${bookId}/chapters/${ch.number}/reject`); refetch(); }}
-                        className={`px-2 py-1 text-xs ${c.btnDanger} rounded`}
-                      >
-                        {t("book.reject")}
-                      </button>
-                    </div>
-                  )}
+                  <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>
+                    <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`}
+                    >
+                      Detect
+                    </button>
+                  </div>
                 </td>
               </tr>
             ))}

+ 137 - 0
packages/studio/src/pages/GenreManager.tsx

@@ -0,0 +1,137 @@
+import { useApi, postApi } from "../hooks/use-api";
+import { useState } from "react";
+import type { Theme } from "../hooks/use-theme";
+import type { TFunction } from "../hooks/use-i18n";
+import { useColors } from "../hooks/use-colors";
+
+interface GenreInfo {
+  readonly id: string;
+  readonly name: string;
+  readonly source: "project" | "builtin";
+  readonly language: "zh" | "en";
+}
+
+interface GenreDetail {
+  readonly profile: {
+    readonly name: string;
+    readonly id: string;
+    readonly language: string;
+    readonly chapterTypes: ReadonlyArray<string>;
+    readonly fatigueWords: ReadonlyArray<string>;
+    readonly numericalSystem: boolean;
+    readonly powerScaling: boolean;
+    readonly pacingRule: string;
+    readonly auditDimensions: ReadonlyArray<number>;
+  };
+  readonly body: string;
+}
+
+interface Nav {
+  toDashboard: () => void;
+}
+
+export function GenreManager({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
+  const c = useColors(theme);
+  const { data, refetch } = useApi<{ genres: ReadonlyArray<GenreInfo> }>("/genres");
+  const [selected, setSelected] = useState<string | null>(null);
+  const { data: detail } = useApi<GenreDetail>(selected ? `/genres/${selected}` : "");
+
+  const handleCopy = async (id: string) => {
+    await postApi(`/genres/${id}/copy`);
+    alert(`Copied ${id} to project genres/`);
+    refetch();
+  };
+
+  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("create.genre")}</span>
+      </div>
+
+      <h1 className="font-serif text-3xl">{t("create.genre")}</h1>
+
+      <div className="grid grid-cols-[250px_1fr] gap-6">
+        {/* Genre list */}
+        <div className={`border ${c.cardStatic} rounded-lg overflow-hidden`}>
+          {data?.genres.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"
+              }`}
+            >
+              <div className="text-sm font-medium">{g.name}</div>
+              <div className="text-xs text-muted-foreground mt-0.5">
+                {g.id} · {g.language} · {g.source}
+              </div>
+            </button>
+          ))}
+        </div>
+
+        {/* Detail panel */}
+        <div className={`border ${c.cardStatic} rounded-lg p-6 min-h-[400px]`}>
+          {selected && detail ? (
+            <div className="space-y-6">
+              <div className="flex items-start justify-between">
+                <div>
+                  <h2 className="text-xl font-medium">{detail.profile.name}</h2>
+                  <div className="text-sm text-muted-foreground mt-1">
+                    {detail.profile.id} · {detail.profile.language} ·
+                    {detail.profile.numericalSystem ? " Numerical" : ""}
+                    {detail.profile.powerScaling ? " Power" : ""}
+                  </div>
+                </div>
+                <button
+                  onClick={() => handleCopy(selected)}
+                  className={`px-3 py-1.5 text-sm ${c.btnSecondary} rounded-md`}
+                >
+                  Copy to Project
+                </button>
+              </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>
+                  ))}
+                </div>
+              </div>
+
+              <div>
+                <div className="text-xs text-muted-foreground uppercase tracking-wide mb-2">Fatigue Words</div>
+                <div className="flex gap-2 flex-wrap">
+                  {detail.profile.fatigueWords.slice(0, 15).map((w) => (
+                    <span key={w} className="px-2 py-1 text-xs bg-secondary rounded">{w}</span>
+                  ))}
+                  {detail.profile.fatigueWords.length > 15 && (
+                    <span className="text-xs text-muted-foreground">+{detail.profile.fatigueWords.length - 15}</span>
+                  )}
+                </div>
+              </div>
+
+              <div>
+                <div className="text-xs text-muted-foreground uppercase tracking-wide mb-2">Pacing</div>
+                <div className="text-sm">{detail.profile.pacingRule || "—"}</div>
+              </div>
+
+              <div>
+                <div className="text-xs text-muted-foreground uppercase tracking-wide mb-2">Rules</div>
+                <pre className="text-sm leading-relaxed whitespace-pre-wrap font-mono text-foreground/80 bg-muted/30 p-4 rounded-md max-h-[300px] overflow-y-auto">
+                  {detail.body || "—"}
+                </pre>
+              </div>
+            </div>
+          ) : (
+            <div className="text-muted-foreground text-sm italic flex items-center justify-center h-full">
+              Select a genre to view details
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}