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

feat(studio): add backend management — daemon, logs, config edit, truth files

New pages:
- DaemonControl: start/stop daemon from UI, real-time SSE event log
- LogViewer: browse last 100 inkos.log entries with level coloring
- TruthFiles: split-pane browser for all truth files (story_bible,
  current_state, ledger, hooks, summaries, etc.)
- ConfigView: upgraded to support inline editing (temperature, maxTokens,
  stream, language) with save to inkos.json

New API endpoints:
- GET/POST /api/daemon, /api/daemon/start, /api/daemon/stop
- GET /api/logs (last 100 JSON Lines entries)
- GET /api/books/:id/truth (list all truth files with preview)
- PUT /api/project (update config settings)

Nav: added Daemon + Logs to header navigation
BookDetail: added "Truth Files" button
Ma 7 месяцев назад
Родитель
Сommit
6ca7c7b008

+ 22 - 7
packages/studio/src/App.tsx

@@ -5,6 +5,9 @@ import { BookCreate } from "./pages/BookCreate";
 import { ChapterReader } from "./pages/ChapterReader";
 import { ChapterReader } from "./pages/ChapterReader";
 import { Analytics } from "./pages/Analytics";
 import { Analytics } from "./pages/Analytics";
 import { ConfigView } from "./pages/ConfigView";
 import { ConfigView } from "./pages/ConfigView";
+import { TruthFiles } from "./pages/TruthFiles";
+import { DaemonControl } from "./pages/DaemonControl";
+import { LogViewer } from "./pages/LogViewer";
 import { useSSE } from "./hooks/use-sse";
 import { useSSE } from "./hooks/use-sse";
 import { useTheme } from "./hooks/use-theme";
 import { useTheme } from "./hooks/use-theme";
 import { useI18n } from "./hooks/use-i18n";
 import { useI18n } from "./hooks/use-i18n";
@@ -15,7 +18,10 @@ type Route =
   | { page: "book-create" }
   | { page: "book-create" }
   | { page: "chapter"; bookId: string; chapterNumber: number }
   | { page: "chapter"; bookId: string; chapterNumber: number }
   | { page: "analytics"; bookId: string }
   | { page: "analytics"; bookId: string }
-  | { page: "config" };
+  | { page: "config" }
+  | { page: "truth"; bookId: string }
+  | { page: "daemon" }
+  | { page: "logs" };
 
 
 export function App() {
 export function App() {
   const [route, setRoute] = useState<Route>({ page: "dashboard" });
   const [route, setRoute] = useState<Route>({ page: "dashboard" });
@@ -37,11 +43,21 @@ export function App() {
       setRoute({ page: "chapter", bookId, chapterNumber }),
       setRoute({ page: "chapter", bookId, chapterNumber }),
     toAnalytics: (bookId: string) => setRoute({ page: "analytics", bookId }),
     toAnalytics: (bookId: string) => setRoute({ page: "analytics", bookId }),
     toConfig: () => setRoute({ page: "config" }),
     toConfig: () => setRoute({ page: "config" }),
+    toTruth: (bookId: string) => setRoute({ page: "truth", bookId }),
+    toDaemon: () => setRoute({ page: "daemon" }),
+    toLogs: () => setRoute({ page: "logs" }),
   };
   };
 
 
+  const navItems = [
+    { label: t("nav.books"), action: nav.toDashboard, active: route.page === "dashboard" || route.page === "book" || route.page === "chapter" },
+    { label: t("nav.newBook"), action: nav.toBookCreate, active: route.page === "book-create" },
+    { label: "Daemon", action: nav.toDaemon, active: route.page === "daemon" },
+    { label: "Logs", action: nav.toLogs, active: route.page === "logs" },
+    { label: t("nav.config"), action: nav.toConfig, active: route.page === "config" },
+  ];
+
   return (
   return (
     <div className="min-h-screen bg-background text-foreground">
     <div className="min-h-screen bg-background text-foreground">
-      {/* Header — thin, literary, warm */}
       <header className="sticky top-0 z-10 border-b border-border/60 bg-background/90 backdrop-blur-md">
       <header className="sticky top-0 z-10 border-b border-border/60 bg-background/90 backdrop-blur-md">
         <div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
         <div className="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
           <div className="flex items-center gap-8">
           <div className="flex items-center gap-8">
@@ -54,11 +70,7 @@ export function App() {
             </button>
             </button>
 
 
             <nav className="flex gap-1 text-[13px] text-muted-foreground">
             <nav className="flex gap-1 text-[13px] text-muted-foreground">
-              {([
-                { label: t("nav.books"), action: nav.toDashboard, active: route.page === "dashboard" },
-                { label: t("nav.newBook"), action: nav.toBookCreate, active: route.page === "book-create" },
-                { label: t("nav.config"), action: nav.toConfig, active: route.page === "config" },
-              ] as const).map((item) => (
+              {navItems.map((item) => (
                 <button
                 <button
                   key={item.label}
                   key={item.label}
                   onClick={item.action}
                   onClick={item.action}
@@ -97,6 +109,9 @@ export function App() {
         {route.page === "chapter" && <ChapterReader bookId={route.bookId} chapterNumber={route.chapterNumber} nav={nav} theme={theme} t={t} />}
         {route.page === "chapter" && <ChapterReader bookId={route.bookId} chapterNumber={route.chapterNumber} nav={nav} theme={theme} t={t} />}
         {route.page === "analytics" && <Analytics bookId={route.bookId} nav={nav} theme={theme} t={t} />}
         {route.page === "analytics" && <Analytics bookId={route.bookId} nav={nav} theme={theme} t={t} />}
         {route.page === "config" && <ConfigView nav={nav} theme={theme} t={t} />}
         {route.page === "config" && <ConfigView nav={nav} theme={theme} t={t} />}
+        {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} />}
       </main>
       </main>
     </div>
     </div>
   );
   );

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

@@ -303,9 +303,121 @@ export function createStudioServer(config: ProjectConfig, root: string) {
       language: config.language,
       language: config.language,
       model: config.llm.model,
       model: config.llm.model,
       provider: config.llm.provider,
       provider: config.llm.provider,
+      baseUrl: config.llm.baseUrl,
+      stream: config.llm.stream,
+      temperature: config.llm.temperature,
+      maxTokens: config.llm.maxTokens,
     });
     });
   });
   });
 
 
+  // --- Config editing ---
+
+  app.put("/api/project", async (c) => {
+    const updates = await c.req.json<Record<string, unknown>>();
+    const configPath = join(root, "inkos.json");
+    try {
+      const raw = await readFile(configPath, "utf-8");
+      const existing = JSON.parse(raw);
+      // Merge LLM settings
+      if (updates.temperature !== undefined) existing.llm.temperature = updates.temperature;
+      if (updates.maxTokens !== undefined) existing.llm.maxTokens = updates.maxTokens;
+      if (updates.stream !== undefined) existing.llm.stream = updates.stream;
+      if (updates.language !== undefined) existing.language = updates.language;
+      const { writeFile: writeFileFs } = await import("node:fs/promises");
+      await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
+      return c.json({ ok: true });
+    } catch (e) {
+      return c.json({ error: String(e) }, 500);
+    }
+  });
+
+  // --- Truth files browser ---
+
+  app.get("/api/books/:id/truth", async (c) => {
+    const id = c.req.param("id");
+    const bookDir = state.bookDir(id);
+    const storyDir = join(bookDir, "story");
+    try {
+      const files = await readdir(storyDir);
+      const mdFiles = files.filter((f) => f.endsWith(".md") || f.endsWith(".json"));
+      const result = await Promise.all(
+        mdFiles.map(async (f) => {
+          const content = await readFile(join(storyDir, f), "utf-8");
+          return { name: f, size: content.length, preview: content.slice(0, 200) };
+        }),
+      );
+      return c.json({ files: result });
+    } catch {
+      return c.json({ files: [] });
+    }
+  });
+
+  // --- Daemon control ---
+
+  let schedulerInstance: import("@actalk/inkos-core").Scheduler | null = null;
+
+  app.get("/api/daemon", (c) => {
+    return c.json({
+      running: schedulerInstance?.isRunning ?? false,
+    });
+  });
+
+  app.post("/api/daemon/start", async (c) => {
+    if (schedulerInstance?.isRunning) {
+      return c.json({ error: "Daemon already running" }, 400);
+    }
+    try {
+      const { Scheduler } = await import("@actalk/inkos-core");
+      schedulerInstance = new Scheduler({
+        ...buildPipelineConfig(),
+        radarCron: config.daemon.schedule.radarCron,
+        writeCron: config.daemon.schedule.writeCron,
+        maxConcurrentBooks: config.daemon.maxConcurrentBooks,
+        chaptersPerCycle: config.daemon.chaptersPerCycle,
+        retryDelayMs: config.daemon.retryDelayMs,
+        cooldownAfterChapterMs: config.daemon.cooldownAfterChapterMs,
+        maxChaptersPerDay: config.daemon.maxChaptersPerDay,
+        onChapterComplete: (bookId, chapter, status) => {
+          broadcast("daemon:chapter", { bookId, chapter, status });
+        },
+        onError: (bookId, error) => {
+          broadcast("daemon:error", { bookId, error: error.message });
+        },
+      });
+      await schedulerInstance.start();
+      broadcast("daemon:started", {});
+      return c.json({ ok: true, running: true });
+    } catch (e) {
+      return c.json({ error: String(e) }, 500);
+    }
+  });
+
+  app.post("/api/daemon/stop", (c) => {
+    if (!schedulerInstance?.isRunning) {
+      return c.json({ error: "Daemon not running" }, 400);
+    }
+    schedulerInstance.stop();
+    schedulerInstance = null;
+    broadcast("daemon:stopped", {});
+    return c.json({ ok: true, running: false });
+  });
+
+  // --- Logs ---
+
+  app.get("/api/logs", async (c) => {
+    const logPath = join(root, "inkos.log");
+    try {
+      const content = await readFile(logPath, "utf-8");
+      const lines = content.trim().split("\n").slice(-100);
+      const entries = lines.map((line) => {
+        try { return JSON.parse(line); } catch { return { message: line }; }
+      });
+      return c.json({ entries });
+    } catch {
+      return c.json({ entries: [] });
+    }
+  });
+
   return app;
   return app;
 }
 }
 
 

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

@@ -129,6 +129,12 @@ export function BookDetail({ bookId, nav, theme, t }: { bookId: string; nav: Nav
               {t("book.approveAll")} ({reviewCount})
               {t("book.approveAll")} ({reviewCount})
             </button>
             </button>
           )}
           )}
+          <button
+            onClick={() => (nav as { toTruth?: (id: string) => void }).toTruth?.(bookId)}
+            className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors`}
+          >
+            Truth Files
+          </button>
           <button
           <button
             onClick={() => nav.toAnalytics(bookId)}
             onClick={() => nav.toAnalytics(bookId)}
             className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors`}
             className={`px-4 py-2 text-sm ${c.btnSecondary} rounded-md transition-colors`}

+ 133 - 20
packages/studio/src/pages/ConfigView.tsx

@@ -1,4 +1,5 @@
-import { useApi } from "../hooks/use-api";
+import { useApi, postApi } from "../hooks/use-api";
+import { useState } from "react";
 import type { Theme } from "../hooks/use-theme";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import type { TFunction } from "../hooks/use-i18n";
 import { useColors } from "../hooks/use-colors";
 import { useColors } from "../hooks/use-colors";
@@ -8,6 +9,10 @@ interface ProjectInfo {
   readonly language: string;
   readonly language: string;
   readonly model: string;
   readonly model: string;
   readonly provider: string;
   readonly provider: string;
+  readonly baseUrl: string;
+  readonly stream: boolean;
+  readonly temperature: number;
+  readonly maxTokens: number;
 }
 }
 
 
 interface Nav {
 interface Nav {
@@ -16,41 +21,149 @@ interface Nav {
 
 
 export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
 export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
   const c = useColors(theme);
   const c = useColors(theme);
-  const { data, loading, error } = useApi<ProjectInfo>("/project");
+  const { data, loading, error, refetch } = useApi<ProjectInfo>("/project");
+  const [editing, setEditing] = useState(false);
+  const [saving, setSaving] = useState(false);
+  const [form, setForm] = useState<Record<string, unknown>>({});
 
 
-  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="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 (!data) return null;
   if (!data) return null;
 
 
+  const startEdit = () => {
+    setForm({
+      temperature: data.temperature,
+      maxTokens: data.maxTokens,
+      stream: data.stream,
+      language: data.language,
+    });
+    setEditing(true);
+  };
+
+  const handleSave = async () => {
+    setSaving(true);
+    try {
+      await fetch("/api/project", {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify(form),
+      });
+      setEditing(false);
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Failed");
+    } finally {
+      setSaving(false);
+    }
+  };
+
   return (
   return (
-    <div className="max-w-lg mx-auto space-y-6">
-      <div className={`flex items-center gap-2 text-sm ${c.muted}`}>
+    <div className="max-w-xl mx-auto 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>
         <button onClick={nav.toDashboard} className={c.link}>{t("bread.home")}</button>
-        <span>/</span>
-        <span className={c.subtle}>{t("bread.config")}</span>
+        <span className="text-border">/</span>
+        <span className="text-foreground">{t("bread.config")}</span>
       </div>
       </div>
 
 
-      <h1 className="text-2xl font-semibold">{t("config.title")}</h1>
+      <div className="flex items-baseline justify-between">
+        <h1 className="font-serif text-3xl">{t("config.title")}</h1>
+        {!editing && (
+          <button onClick={startEdit} className={`px-3 py-2 text-xs rounded-md ${c.btnSecondary}`}>
+            Edit
+          </button>
+        )}
+      </div>
 
 
-      <div className={`border ${c.cardStatic} rounded-lg divide-y ${c.tableDivide}`}>
-        <Row label={t("config.project")} value={data.name} c={c} />
-        <Row label={t("config.language")} value={data.language === "en" ? "English" : "Chinese"} c={c} />
-        <Row label={t("config.provider")} value={data.provider} c={c} />
-        <Row label={t("config.model")} value={data.model} c={c} />
+      <div className={`border ${c.cardStatic} rounded-lg divide-y divide-border/40`}>
+        <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 />
+
+        {editing ? (
+          <>
+            <EditRow
+              label={t("config.language")}
+              value={form.language as string}
+              onChange={(v) => setForm({ ...form, language: v })}
+              type="select"
+              options={[{ value: "zh", label: "Chinese" }, { value: "en", label: "English" }]}
+              c={c}
+            />
+            <EditRow
+              label="Temperature"
+              value={String(form.temperature)}
+              onChange={(v) => setForm({ ...form, temperature: parseFloat(v) })}
+              type="number"
+              c={c}
+            />
+            <EditRow
+              label="Max Tokens"
+              value={String(form.maxTokens)}
+              onChange={(v) => setForm({ ...form, maxTokens: parseInt(v, 10) })}
+              type="number"
+              c={c}
+            />
+            <EditRow
+              label="Stream"
+              value={String(form.stream)}
+              onChange={(v) => setForm({ ...form, stream: v === "true" })}
+              type="select"
+              options={[{ value: "true", label: "Enabled" }, { value: "false", label: "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"} />
+          </>
+        )}
       </div>
       </div>
 
 
-      <p className={`text-sm ${c.muted}`}>
-        {t("config.editHint")} <code className={`${c.code} px-1.5 py-0.5 rounded`}>inkos config set &lt;key&gt; &lt;value&gt;</code>
-      </p>
+      {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
+          </button>
+          <button onClick={handleSave} disabled={saving} className={`px-4 py-2.5 text-sm rounded-md ${c.btnPrimary} disabled:opacity-50`}>
+            {saving ? "Saving..." : "Save"}
+          </button>
+        </div>
+      )}
     </div>
     </div>
   );
   );
 }
 }
 
 
-function Row({ label, value, c }: { label: string; value: string; c: ReturnType<typeof useColors> }) {
+function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
   return (
   return (
     <div className="flex justify-between px-4 py-3">
     <div className="flex justify-between px-4 py-3">
-      <span className={c.subtle}>{label}</span>
-      <span className={c.mono}>{value}</span>
+      <span className="text-muted-foreground text-sm">{label}</span>
+      <span className={mono ? "font-mono text-sm" : "text-sm"}>{value}</span>
+    </div>
+  );
+}
+
+function EditRow({ label, value, onChange, type, options, c }: {
+  label: string;
+  value: string;
+  onChange: (v: string) => void;
+  type: "number" | "select";
+  options?: ReadonlyArray<{ value: string; label: string }>;
+  c: ReturnType<typeof useColors>;
+}) {
+  return (
+    <div className="flex justify-between items-center px-4 py-2.5">
+      <span className="text-muted-foreground text-sm">{label}</span>
+      {type === "select" && options ? (
+        <select value={value} onChange={(e) => onChange(e.target.value)} className={`${c.input} rounded px-2 py-1 text-sm w-32`}>
+          {options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
+        </select>
+      ) : (
+        <input type="number" value={value} onChange={(e) => onChange(e.target.value)} className={`${c.input} rounded px-2 py-1 text-sm w-32 text-right`} />
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 109 - 0
packages/studio/src/pages/DaemonControl.tsx

@@ -0,0 +1,109 @@
+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";
+import type { SSEMessage } from "../hooks/use-sse";
+
+interface Nav {
+  toDashboard: () => void;
+}
+
+export function DaemonControl({ nav, theme, t, sse }: { nav: Nav; theme: Theme; t: TFunction; sse: { messages: ReadonlyArray<SSEMessage> } }) {
+  const c = useColors(theme);
+  const { data, refetch } = useApi<{ running: boolean }>("/daemon");
+  const [loading, setLoading] = useState(false);
+
+  const daemonEvents = sse.messages
+    .filter((m) => m.event.startsWith("daemon:") || m.event === "log")
+    .slice(-20);
+
+  const handleStart = async () => {
+    setLoading(true);
+    try {
+      await postApi("/daemon/start");
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Failed");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleStop = async () => {
+    setLoading(true);
+    try {
+      await postApi("/daemon/stop");
+      refetch();
+    } catch (e) {
+      alert(e instanceof Error ? e.message : "Failed");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const isRunning = data?.running ?? 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 className="text-foreground">Daemon</span>
+      </div>
+
+      <div className="flex items-baseline justify-between">
+        <h1 className="font-serif text-3xl">Daemon</h1>
+        <div className="flex items-center gap-3">
+          <span className={`text-xs uppercase tracking-widest ${isRunning ? "text-emerald-500" : "text-muted-foreground/50"}`}>
+            {isRunning ? "Running" : "Stopped"}
+          </span>
+          {isRunning ? (
+            <button
+              onClick={handleStop}
+              disabled={loading}
+              className={`px-4 py-2.5 text-sm rounded-md ${c.btnDanger} disabled:opacity-50`}
+            >
+              {loading ? "Stopping..." : "Stop"}
+            </button>
+          ) : (
+            <button
+              onClick={handleStart}
+              disabled={loading}
+              className={`px-4 py-2.5 text-sm rounded-md ${c.btnPrimary} disabled:opacity-50`}
+            >
+              {loading ? "Starting..." : "Start"}
+            </button>
+          )}
+        </div>
+      </div>
+
+      {/* Daemon event log */}
+      <div className={`border ${c.cardStatic} rounded-lg`}>
+        <div className="px-4 py-3 border-b border-border/40">
+          <span className="text-xs uppercase tracking-widest text-muted-foreground">Event Log</span>
+        </div>
+        <div className="p-4 max-h-[500px] overflow-y-auto">
+          {daemonEvents.length > 0 ? (
+            <div className="space-y-1 font-mono text-[13px]">
+              {daemonEvents.map((msg, i) => {
+                const d = msg.data as Record<string, unknown>;
+                return (
+                  <div key={i} className="leading-relaxed text-muted-foreground">
+                    <span className="text-primary/50">{msg.event}</span>
+                    <span className="text-border mx-1.5">›</span>
+                    <span>{d.message ?? d.bookId ?? JSON.stringify(d)}</span>
+                  </div>
+                );
+              })}
+            </div>
+          ) : (
+            <div className="text-muted-foreground/40 text-sm italic py-8 text-center">
+              {isRunning ? "Waiting for events..." : "Start the daemon to see events"}
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 82 - 0
packages/studio/src/pages/LogViewer.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";
+
+interface LogEntry {
+  readonly level?: string;
+  readonly tag?: string;
+  readonly message: string;
+  readonly timestamp?: string;
+}
+
+interface Nav {
+  toDashboard: () => void;
+}
+
+const LEVEL_COLORS: Record<string, string> = {
+  error: "text-destructive",
+  warn: "text-amber-500",
+  info: "text-primary/70",
+  debug: "text-muted-foreground/50",
+};
+
+export function LogViewer({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
+  const c = useColors(theme);
+  const { data, refetch } = useApi<{ entries: ReadonlyArray<LogEntry> }>("/logs");
+
+  return (
+    <div className="space-y-6">
+      <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 className="text-foreground">Logs</span>
+      </div>
+
+      <div className="flex items-baseline justify-between">
+        <h1 className="font-serif text-3xl">Logs</h1>
+        <button
+          onClick={() => refetch()}
+          className={`px-3 py-2 text-xs rounded-md ${c.btnSecondary}`}
+        >
+          Refresh
+        </button>
+      </div>
+
+      <div className={`border ${c.cardStatic} rounded-lg overflow-hidden`}>
+        <div className="p-4 max-h-[600px] overflow-y-auto">
+          {data?.entries && data.entries.length > 0 ? (
+            <div className="space-y-0.5 font-mono text-[12px] leading-relaxed">
+              {data.entries.map((entry, i) => (
+                <div key={i} className="flex gap-2">
+                  {entry.timestamp && (
+                    <span className="text-muted-foreground/40 shrink-0 w-20 tabular-nums">
+                      {new Date(entry.timestamp).toLocaleTimeString()}
+                    </span>
+                  )}
+                  {entry.level && (
+                    <span className={`shrink-0 w-12 uppercase ${LEVEL_COLORS[entry.level] ?? "text-muted-foreground"}`}>
+                      {entry.level}
+                    </span>
+                  )}
+                  {entry.tag && (
+                    <span className="text-primary/40 shrink-0">[{entry.tag}]</span>
+                  )}
+                  <span className="text-foreground/80">{entry.message}</span>
+                </div>
+              ))}
+            </div>
+          ) : (
+            <div className="text-muted-foreground/40 text-sm italic py-12 text-center">
+              No log entries. Logs appear after running write/draft/daemon operations.
+            </div>
+          )}
+        </div>
+      </div>
+
+      <p className="text-xs text-muted-foreground/50">
+        Showing last 100 entries from <code className="font-mono bg-muted/50 px-1 rounded">inkos.log</code>
+      </p>
+    </div>
+  );
+}

+ 73 - 0
packages/studio/src/pages/TruthFiles.tsx

@@ -0,0 +1,73 @@
+import { 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";
+
+interface TruthFile {
+  readonly name: string;
+  readonly size: number;
+  readonly preview: string;
+}
+
+interface Nav {
+  toBook: (id: string) => void;
+  toDashboard: () => void;
+}
+
+export function TruthFiles({ bookId, nav, theme, t }: { bookId: string; nav: Nav; theme: Theme; t: TFunction }) {
+  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 }>(
+    selected ? `/books/${bookId}/truth/${selected}` : "",
+  );
+
+  return (
+    <div className="space-y-6">
+      <div className="flex items-center gap-2 text-sm text-muted-foreground">
+        <button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
+        <span className="text-border">/</span>
+        <button onClick={() => nav.toBook(bookId)} className={c.link}>{bookId}</button>
+        <span className="text-border">/</span>
+        <span className="text-foreground">Truth Files</span>
+      </div>
+
+      <h1 className="font-serif text-3xl">Truth Files</h1>
+
+      <div className="grid grid-cols-[240px_1fr] gap-6">
+        {/* File list */}
+        <div className={`border ${c.cardStatic} rounded-lg overflow-hidden`}>
+          {data?.files.map((f) => (
+            <button
+              key={f.name}
+              onClick={() => setSelected(f.name)}
+              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"
+                  : "hover:bg-muted/30 text-muted-foreground"
+              }`}
+            >
+              <div className="font-mono text-xs truncate">{f.name}</div>
+              <div className="text-[11px] text-muted-foreground/60 mt-0.5">{f.size.toLocaleString()} chars</div>
+            </button>
+          ))}
+          {(!data?.files || data.files.length === 0) && (
+            <div className="px-3 py-4 text-sm text-muted-foreground text-center">No truth files</div>
+          )}
+        </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>
+          ) : selected && fileData?.content === null ? (
+            <div className="text-muted-foreground text-sm">File not found</div>
+          ) : (
+            <div className="text-muted-foreground/50 text-sm italic">Select a file to view</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}