server.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import { Hono } from "hono";
  2. import { cors } from "hono/cors";
  3. import { streamSSE } from "hono/streaming";
  4. import { serve } from "@hono/node-server";
  5. import {
  6. StateManager,
  7. PipelineRunner,
  8. createLLMClient,
  9. createLogger,
  10. computeAnalytics,
  11. type PipelineConfig,
  12. type ProjectConfig,
  13. type LogSink,
  14. type LogEntry,
  15. } from "@actalk/inkos-core";
  16. import { readFile, readdir } from "node:fs/promises";
  17. import { join } from "node:path";
  18. // --- Event bus for SSE ---
  19. type EventHandler = (event: string, data: unknown) => void;
  20. const subscribers = new Set<EventHandler>();
  21. function broadcast(event: string, data: unknown): void {
  22. for (const handler of subscribers) {
  23. handler(event, data);
  24. }
  25. }
  26. // --- Server factory ---
  27. export function createStudioServer(config: ProjectConfig, root: string) {
  28. const app = new Hono();
  29. const state = new StateManager(root);
  30. app.use("/*", cors());
  31. // Logger sink that broadcasts to SSE
  32. const sseSink: LogSink = {
  33. write(entry: LogEntry): void {
  34. broadcast("log", { level: entry.level, tag: entry.tag, message: entry.message });
  35. },
  36. };
  37. function buildPipelineConfig(): PipelineConfig {
  38. const logger = createLogger({ tag: "studio", sinks: [sseSink] });
  39. return {
  40. client: createLLMClient(config.llm),
  41. model: config.llm.model,
  42. projectRoot: root,
  43. defaultLLMConfig: config.llm,
  44. modelOverrides: config.modelOverrides,
  45. notifyChannels: config.notify,
  46. logger,
  47. onStreamProgress: (progress) => {
  48. if (progress.status === "streaming") {
  49. broadcast("llm:progress", {
  50. elapsedMs: progress.elapsedMs,
  51. totalChars: progress.totalChars,
  52. chineseChars: progress.chineseChars,
  53. });
  54. }
  55. },
  56. };
  57. }
  58. // --- Books ---
  59. app.get("/api/books", async (c) => {
  60. const bookIds = await state.listBooks();
  61. const books = await Promise.all(
  62. bookIds.map(async (id) => {
  63. const book = await state.loadBookConfig(id);
  64. const nextChapter = await state.getNextChapterNumber(id);
  65. return { ...book, chaptersWritten: nextChapter - 1 };
  66. }),
  67. );
  68. return c.json({ books });
  69. });
  70. app.get("/api/books/:id", async (c) => {
  71. const id = c.req.param("id");
  72. try {
  73. const book = await state.loadBookConfig(id);
  74. const chapters = await state.loadChapterIndex(id);
  75. const nextChapter = await state.getNextChapterNumber(id);
  76. return c.json({ book, chapters, nextChapter });
  77. } catch {
  78. return c.json({ error: `Book "${id}" not found` }, 404);
  79. }
  80. });
  81. // --- Genres ---
  82. app.get("/api/genres", async (c) => {
  83. const { listAvailableGenres } = await import("@actalk/inkos-core");
  84. const genres = await listAvailableGenres(root);
  85. return c.json({ genres });
  86. });
  87. // --- Book Create ---
  88. app.post("/api/books/create", async (c) => {
  89. const body = await c.req.json<{
  90. title: string;
  91. genre: string;
  92. language?: string;
  93. chapterWordCount?: number;
  94. targetChapters?: number;
  95. }>();
  96. const now = new Date().toISOString();
  97. const bookId = body.title
  98. .toLowerCase()
  99. .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
  100. .replace(/-+/g, "-")
  101. .slice(0, 30);
  102. const bookConfig = {
  103. id: bookId,
  104. title: body.title,
  105. platform: "other" as const,
  106. genre: body.genre as "xuanhuan",
  107. status: "outlining" as const,
  108. targetChapters: body.targetChapters ?? 200,
  109. chapterWordCount: body.chapterWordCount ?? 3000,
  110. ...(body.language === "en" ? { language: "en" as const } : body.language === "zh" ? { language: "zh" as const } : {}),
  111. createdAt: now,
  112. updatedAt: now,
  113. };
  114. broadcast("book:creating", { bookId, title: body.title });
  115. const pipeline = new PipelineRunner(buildPipelineConfig());
  116. pipeline.initBook(bookConfig).then(
  117. () => broadcast("book:created", { bookId }),
  118. (e) => broadcast("book:error", { bookId, error: e instanceof Error ? e.message : String(e) }),
  119. );
  120. return c.json({ status: "creating", bookId });
  121. });
  122. // --- Chapters ---
  123. app.get("/api/books/:id/chapters/:num", async (c) => {
  124. const id = c.req.param("id");
  125. const num = parseInt(c.req.param("num"), 10);
  126. const bookDir = state.bookDir(id);
  127. const chaptersDir = join(bookDir, "chapters");
  128. try {
  129. const files = await readdir(chaptersDir);
  130. const paddedNum = String(num).padStart(4, "0");
  131. const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  132. if (!match) return c.json({ error: "Chapter not found" }, 404);
  133. const content = await readFile(join(chaptersDir, match), "utf-8");
  134. return c.json({ chapterNumber: num, filename: match, content });
  135. } catch {
  136. return c.json({ error: "Chapter not found" }, 404);
  137. }
  138. });
  139. // --- Truth files ---
  140. const TRUTH_FILES = [
  141. "story_bible.md", "volume_outline.md", "current_state.md",
  142. "particle_ledger.md", "pending_hooks.md", "chapter_summaries.md",
  143. "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
  144. "style_guide.md", "parent_canon.md", "fanfic_canon.md", "book_rules.md",
  145. ];
  146. app.get("/api/books/:id/truth/:file", async (c) => {
  147. const id = c.req.param("id");
  148. const file = c.req.param("file");
  149. if (!TRUTH_FILES.includes(file)) {
  150. return c.json({ error: "Invalid truth file" }, 400);
  151. }
  152. const bookDir = state.bookDir(id);
  153. try {
  154. const content = await readFile(join(bookDir, "story", file), "utf-8");
  155. return c.json({ file, content });
  156. } catch {
  157. return c.json({ file, content: null });
  158. }
  159. });
  160. // --- Analytics ---
  161. app.get("/api/books/:id/analytics", async (c) => {
  162. const id = c.req.param("id");
  163. try {
  164. const chapters = await state.loadChapterIndex(id);
  165. return c.json(computeAnalytics(id, chapters));
  166. } catch {
  167. return c.json({ error: `Book "${id}" not found` }, 404);
  168. }
  169. });
  170. // --- Actions ---
  171. app.post("/api/books/:id/write-next", async (c) => {
  172. const id = c.req.param("id");
  173. const body = await c.req.json<{ wordCount?: number }>().catch(() => ({ wordCount: undefined }));
  174. broadcast("write:start", { bookId: id });
  175. // Fire and forget — progress/completion/errors pushed via SSE
  176. const pipeline = new PipelineRunner(buildPipelineConfig());
  177. pipeline.writeNextChapter(id, body.wordCount).then(
  178. (result) => {
  179. broadcast("write:complete", { bookId: id, chapterNumber: result.chapterNumber, status: result.status, title: result.title, wordCount: result.wordCount });
  180. },
  181. (e) => {
  182. broadcast("write:error", { bookId: id, error: e instanceof Error ? e.message : String(e) });
  183. },
  184. );
  185. return c.json({ status: "writing", bookId: id });
  186. });
  187. app.post("/api/books/:id/draft", async (c) => {
  188. const id = c.req.param("id");
  189. const body = await c.req.json<{ wordCount?: number; context?: string }>().catch(() => ({ wordCount: undefined, context: undefined }));
  190. broadcast("draft:start", { bookId: id });
  191. const pipeline = new PipelineRunner(buildPipelineConfig());
  192. pipeline.writeDraft(id, body.context, body.wordCount).then(
  193. (result) => {
  194. broadcast("draft:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount });
  195. },
  196. (e) => {
  197. broadcast("draft:error", { bookId: id, error: e instanceof Error ? e.message : String(e) });
  198. },
  199. );
  200. return c.json({ status: "drafting", bookId: id });
  201. });
  202. app.post("/api/books/:id/chapters/:num/approve", async (c) => {
  203. const id = c.req.param("id");
  204. const num = parseInt(c.req.param("num"), 10);
  205. try {
  206. const index = await state.loadChapterIndex(id);
  207. const updated = index.map((ch) =>
  208. ch.number === num ? { ...ch, status: "approved" as const } : ch,
  209. );
  210. await state.saveChapterIndex(id, updated);
  211. return c.json({ ok: true, chapterNumber: num, status: "approved" });
  212. } catch (e) {
  213. return c.json({ error: String(e) }, 500);
  214. }
  215. });
  216. app.post("/api/books/:id/chapters/:num/reject", async (c) => {
  217. const id = c.req.param("id");
  218. const num = parseInt(c.req.param("num"), 10);
  219. try {
  220. const index = await state.loadChapterIndex(id);
  221. const updated = index.map((ch) =>
  222. ch.number === num ? { ...ch, status: "rejected" as const } : ch,
  223. );
  224. await state.saveChapterIndex(id, updated);
  225. return c.json({ ok: true, chapterNumber: num, status: "rejected" });
  226. } catch (e) {
  227. return c.json({ error: String(e) }, 500);
  228. }
  229. });
  230. // --- SSE ---
  231. app.get("/api/events", (c) => {
  232. return streamSSE(c, async (stream) => {
  233. const handler: EventHandler = (event, data) => {
  234. stream.writeSSE({ event, data: JSON.stringify(data) });
  235. };
  236. subscribers.add(handler);
  237. // Keep alive
  238. const keepAlive = setInterval(() => {
  239. stream.writeSSE({ event: "ping", data: "" });
  240. }, 30000);
  241. stream.onAbort(() => {
  242. subscribers.delete(handler);
  243. clearInterval(keepAlive);
  244. });
  245. // Block until aborted
  246. await new Promise(() => {});
  247. });
  248. });
  249. // --- Project info ---
  250. app.get("/api/project", (c) => {
  251. return c.json({
  252. name: config.name,
  253. language: config.language,
  254. model: config.llm.model,
  255. provider: config.llm.provider,
  256. });
  257. });
  258. return app;
  259. }
  260. // --- Standalone runner ---
  261. export async function startStudioServer(
  262. root: string,
  263. port = 4567,
  264. options?: { readonly staticDir?: string },
  265. ): Promise<void> {
  266. const { loadProjectConfig } = await import("@actalk/inkos-core");
  267. const config = await loadProjectConfig(root);
  268. const app = createStudioServer(config, root);
  269. // Serve frontend static files — single process for API + frontend
  270. if (options?.staticDir) {
  271. const { readFile: readFileFs } = await import("node:fs/promises");
  272. const { join: joinPath } = await import("node:path");
  273. const { existsSync } = await import("node:fs");
  274. // Serve static assets (js, css, etc.)
  275. app.get("/assets/*", async (c) => {
  276. const filePath = joinPath(options.staticDir!, c.req.path);
  277. try {
  278. const content = await readFileFs(filePath);
  279. const ext = filePath.split(".").pop() ?? "";
  280. const contentTypes: Record<string, string> = {
  281. js: "application/javascript",
  282. css: "text/css",
  283. svg: "image/svg+xml",
  284. png: "image/png",
  285. ico: "image/x-icon",
  286. json: "application/json",
  287. };
  288. return new Response(content, {
  289. headers: { "Content-Type": contentTypes[ext] ?? "application/octet-stream" },
  290. });
  291. } catch {
  292. return c.notFound();
  293. }
  294. });
  295. // SPA fallback — serve index.html for all non-API routes
  296. const indexPath = joinPath(options.staticDir!, "index.html");
  297. if (existsSync(indexPath)) {
  298. const indexHtml = await readFileFs(indexPath, "utf-8");
  299. app.get("*", (c) => {
  300. if (c.req.path.startsWith("/api/")) return c.notFound();
  301. return c.html(indexHtml);
  302. });
  303. }
  304. }
  305. console.log(`InkOS Studio running on http://localhost:${port}`);
  306. serve({ fetch: app.fetch, port });
  307. }