| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365 |
- 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 });
- }
|