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