server.ts 78 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183
  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. createInteractionToolsFromDeps,
  11. computeAnalytics,
  12. loadProjectConfig,
  13. loadProjectSession,
  14. processProjectInteractionRequest,
  15. resolveSessionActiveBook,
  16. findOrCreateBookSession,
  17. listBookSessions,
  18. loadBookSession,
  19. persistBookSession,
  20. appendBookSessionMessage,
  21. runAgentSession,
  22. buildAgentSystemPrompt,
  23. resolveServicePreset,
  24. resolveServiceModel,
  25. loadSecrets,
  26. saveSecrets,
  27. getServiceApiKey,
  28. listModelsForService,
  29. chatCompletion,
  30. GLOBAL_ENV_PATH,
  31. type ResolvedModel,
  32. type PipelineConfig,
  33. type ProjectConfig,
  34. type LogSink,
  35. type LogEntry,
  36. type BookSession,
  37. } from "@actalk/inkos-core";
  38. import { access, readFile, readdir, writeFile } from "node:fs/promises";
  39. import { join } from "node:path";
  40. import { isSafeBookId } from "./safety.js";
  41. import { ApiError } from "./errors.js";
  42. import { buildStudioBookConfig } from "./book-create.js";
  43. // -- Pipeline stage definitions per agent type --
  44. const PIPELINE_STAGES: Record<string, string[]> = {
  45. writer: [
  46. "准备章节输入", "撰写章节草稿", "落盘最终章节",
  47. "生成最终真相文件", "校验真相文件变更", "同步记忆索引",
  48. "更新章节索引与快照",
  49. ],
  50. architect: [
  51. "生成基础设定", "保存书籍配置", "写入基础设定文件",
  52. "初始化控制文档", "创建初始快照",
  53. ],
  54. reviser: [
  55. "加载修订上下文", "修订章节", "落盘修订结果",
  56. "更新索引与快照",
  57. ],
  58. auditor: ["审计章节"],
  59. };
  60. const AGENT_LABELS: Record<string, string> = {
  61. architect: "建书", writer: "写作", auditor: "审计",
  62. reviser: "修订", exporter: "导出",
  63. };
  64. const TOOL_LABELS: Record<string, string> = {
  65. read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
  66. };
  67. function resolveToolLabel(tool: string, agent?: string): string {
  68. if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
  69. return TOOL_LABELS[tool] ?? tool;
  70. }
  71. function summarizeResult(result: unknown): string {
  72. if (typeof result === "string") return result.slice(0, 200);
  73. if (result && typeof result === "object") {
  74. const r = result as Record<string, unknown>;
  75. if (typeof r.content === "string") return r.content.slice(0, 200);
  76. if (typeof r.text === "string") return r.text.slice(0, 200);
  77. }
  78. return String(result).slice(0, 200);
  79. }
  80. function extractToolError(result: unknown): string {
  81. if (typeof result === "string") return result.slice(0, 500);
  82. if (result && typeof result === "object") {
  83. const r = result as Record<string, unknown>;
  84. if (typeof r.content === "string") return r.content.slice(0, 500);
  85. if (r.content && Array.isArray(r.content)) {
  86. const textPart = r.content.find((c: any) => c.type === "text");
  87. if (textPart) return (textPart as any).text?.slice(0, 500) ?? "";
  88. }
  89. }
  90. return String(result).slice(0, 500);
  91. }
  92. interface CollectedToolExec {
  93. id: string;
  94. tool: string;
  95. agent?: string;
  96. label: string;
  97. status: "running" | "completed" | "error";
  98. args?: Record<string, unknown>;
  99. result?: string;
  100. error?: string;
  101. stages?: Array<{ label: string; status: "pending" | "completed" }>;
  102. startedAt: number;
  103. completedAt?: number;
  104. }
  105. // --- Event bus for SSE ---
  106. type EventHandler = (event: string, data: unknown) => void;
  107. const subscribers = new Set<EventHandler>();
  108. const bookCreateStatus = new Map<string, { status: "creating" | "error"; error?: string }>();
  109. interface ServiceConfigEntry {
  110. service: string;
  111. name?: string;
  112. baseUrl?: string;
  113. temperature?: number;
  114. maxTokens?: number;
  115. apiFormat?: "chat" | "responses";
  116. stream?: boolean;
  117. }
  118. type LLMConfigSource = "env" | "studio";
  119. interface EnvConfigSummary {
  120. detected: boolean;
  121. provider: string | null;
  122. baseUrl: string | null;
  123. model: string | null;
  124. hasApiKey: boolean;
  125. }
  126. interface EnvConfigStatus {
  127. project: EnvConfigSummary;
  128. global: EnvConfigSummary;
  129. effectiveSource: "project" | "global" | null;
  130. }
  131. function broadcast(event: string, data: unknown): void {
  132. for (const handler of subscribers) {
  133. handler(event, data);
  134. }
  135. }
  136. function isCustomServiceId(serviceId: string): boolean {
  137. return serviceId === "custom" || serviceId.startsWith("custom:");
  138. }
  139. function serviceConfigKey(entry: ServiceConfigEntry): string {
  140. return entry.service === "custom" ? `custom:${entry.name ?? "Custom"}` : entry.service;
  141. }
  142. function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>): ServiceConfigEntry {
  143. if (serviceId.startsWith("custom:")) {
  144. return {
  145. service: "custom",
  146. name: decodeURIComponent(serviceId.slice("custom:".length)),
  147. ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
  148. ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
  149. ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
  150. ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
  151. ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
  152. };
  153. }
  154. if (serviceId === "custom") {
  155. return {
  156. service: "custom",
  157. ...(typeof value.name === "string" && value.name.length > 0 ? { name: value.name } : {}),
  158. ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
  159. ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
  160. ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
  161. ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
  162. ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
  163. };
  164. }
  165. return {
  166. service: serviceId,
  167. ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
  168. ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
  169. ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
  170. ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
  171. };
  172. }
  173. function normalizeConfigSource(value: unknown): LLMConfigSource {
  174. return value === "studio" ? "studio" : "env";
  175. }
  176. function normalizeServiceConfig(raw: unknown): ServiceConfigEntry[] {
  177. if (Array.isArray(raw)) {
  178. return raw
  179. .filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
  180. .map((entry) => ({
  181. service: typeof entry.service === "string" && entry.service.length > 0 ? entry.service : "custom",
  182. ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
  183. ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
  184. ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
  185. ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
  186. ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
  187. ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
  188. }));
  189. }
  190. if (raw && typeof raw === "object") {
  191. return Object.entries(raw as Record<string, unknown>)
  192. .filter(([, value]) => value && typeof value === "object")
  193. .map(([serviceId, value]) => normalizeServiceEntry(serviceId, value as Record<string, unknown>));
  194. }
  195. return [];
  196. }
  197. function mergeServiceConfig(existing: ServiceConfigEntry[], updates: ServiceConfigEntry[]): ServiceConfigEntry[] {
  198. const merged = new Map(existing.map((entry) => [serviceConfigKey(entry), entry]));
  199. for (const update of updates) {
  200. merged.set(serviceConfigKey(update), update);
  201. }
  202. return [...merged.values()];
  203. }
  204. async function loadRawConfig(root: string): Promise<Record<string, unknown>> {
  205. const configPath = join(root, "inkos.json");
  206. const raw = await readFile(configPath, "utf-8");
  207. return JSON.parse(raw) as Record<string, unknown>;
  208. }
  209. async function saveRawConfig(root: string, config: Record<string, unknown>): Promise<void> {
  210. await writeFile(join(root, "inkos.json"), JSON.stringify(config, null, 2), "utf-8");
  211. }
  212. async function readEnvConfigSummary(path: string): Promise<EnvConfigSummary> {
  213. try {
  214. const raw = await readFile(path, "utf-8");
  215. const values = new Map<string, string>();
  216. for (const line of raw.split(/\r?\n/)) {
  217. const trimmed = line.trim();
  218. if (!trimmed || trimmed.startsWith("#")) continue;
  219. const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
  220. if (!match) continue;
  221. const [, key, value] = match;
  222. values.set(key, value.trim());
  223. }
  224. const provider = values.get("INKOS_LLM_PROVIDER") ?? null;
  225. const baseUrl = values.get("INKOS_LLM_BASE_URL") ?? null;
  226. const model = values.get("INKOS_LLM_MODEL") ?? null;
  227. const apiKey = values.get("INKOS_LLM_API_KEY") ?? "";
  228. const detected = Boolean(provider || baseUrl || model || apiKey);
  229. return {
  230. detected,
  231. provider,
  232. baseUrl,
  233. model,
  234. hasApiKey: apiKey.length > 0,
  235. };
  236. } catch {
  237. return {
  238. detected: false,
  239. provider: null,
  240. baseUrl: null,
  241. model: null,
  242. hasApiKey: false,
  243. };
  244. }
  245. }
  246. async function readEnvConfigStatus(root: string): Promise<EnvConfigStatus> {
  247. const project = await readEnvConfigSummary(join(root, ".env"));
  248. const global = await readEnvConfigSummary(GLOBAL_ENV_PATH);
  249. return {
  250. project,
  251. global,
  252. effectiveSource: project.detected ? "project" : global.detected ? "global" : null,
  253. };
  254. }
  255. async function resolveConfiguredServiceBaseUrl(root: string, serviceId: string, inlineBaseUrl?: string): Promise<string | undefined> {
  256. if (inlineBaseUrl?.trim()) return inlineBaseUrl.trim();
  257. if (!isCustomServiceId(serviceId)) {
  258. return resolveServicePreset(serviceId)?.baseUrl;
  259. }
  260. try {
  261. const config = await loadRawConfig(root);
  262. const services = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services);
  263. const matched = services.find((entry) => serviceConfigKey(entry) === serviceId);
  264. return matched?.baseUrl;
  265. } catch {
  266. return undefined;
  267. }
  268. }
  269. async function resolveConfiguredServiceEntry(root: string, serviceId: string): Promise<ServiceConfigEntry | undefined> {
  270. try {
  271. const config = await loadRawConfig(root);
  272. const services = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services);
  273. return services.find((entry) => serviceConfigKey(entry) === serviceId);
  274. } catch {
  275. return undefined;
  276. }
  277. }
  278. // --- Server factory ---
  279. export function createStudioServer(initialConfig: ProjectConfig, root: string) {
  280. const app = new Hono();
  281. const state = new StateManager(root);
  282. let cachedConfig = initialConfig;
  283. app.use("/*", cors());
  284. // Structured error handler — ApiError returns typed JSON, others return 500
  285. app.onError((error, c) => {
  286. if (error instanceof ApiError) {
  287. return c.json({ error: { code: error.code, message: error.message } }, error.status as 400);
  288. }
  289. return c.json(
  290. { error: { code: "INTERNAL_ERROR", message: "Unexpected server error." } },
  291. 500,
  292. );
  293. });
  294. // BookId validation middleware — blocks path traversal on all book routes
  295. app.use("/api/v1/books/:id/*", async (c, next) => {
  296. const bookId = c.req.param("id");
  297. if (!isSafeBookId(bookId)) {
  298. throw new ApiError(400, "INVALID_BOOK_ID", `Invalid book ID: "${bookId}"`);
  299. }
  300. await next();
  301. });
  302. app.use("/api/v1/books/:id", async (c, next) => {
  303. const bookId = c.req.param("id");
  304. if (!isSafeBookId(bookId)) {
  305. throw new ApiError(400, "INVALID_BOOK_ID", `Invalid book ID: "${bookId}"`);
  306. }
  307. await next();
  308. });
  309. // Logger sink that broadcasts to SSE
  310. const sseSink: LogSink = {
  311. write(entry: LogEntry): void {
  312. broadcast("log", { level: entry.level, tag: entry.tag, message: entry.message });
  313. },
  314. };
  315. // Logger sink that prints to server terminal
  316. const consoleSink: LogSink = {
  317. write(entry: LogEntry): void {
  318. const prefix = `[${entry.tag}]`;
  319. if (entry.level === "warn") console.warn(prefix, entry.message);
  320. else if (entry.level === "error") console.error(prefix, entry.message);
  321. else console.log(prefix, entry.message);
  322. },
  323. };
  324. async function loadCurrentProjectConfig(
  325. options?: { readonly requireApiKey?: boolean },
  326. ): Promise<ProjectConfig> {
  327. const freshConfig = await loadProjectConfig(root, options);
  328. cachedConfig = freshConfig;
  329. return freshConfig;
  330. }
  331. async function buildPipelineConfig(
  332. overrides?: Partial<Pick<PipelineConfig, "externalContext" | "client" | "model">> & {
  333. readonly currentConfig?: ProjectConfig;
  334. },
  335. ): Promise<PipelineConfig> {
  336. const currentConfig = overrides?.currentConfig ?? await loadCurrentProjectConfig();
  337. const logger = createLogger({ tag: "studio", sinks: [sseSink, consoleSink] });
  338. return {
  339. client: overrides?.client ?? createLLMClient(currentConfig.llm),
  340. model: overrides?.model ?? currentConfig.llm.model,
  341. projectRoot: root,
  342. defaultLLMConfig: currentConfig.llm,
  343. modelOverrides: currentConfig.modelOverrides,
  344. notifyChannels: currentConfig.notify,
  345. logger,
  346. onStreamProgress: (progress) => {
  347. broadcast("llm:progress", {
  348. status: progress.status,
  349. elapsedMs: progress.elapsedMs,
  350. totalChars: progress.totalChars,
  351. chineseChars: progress.chineseChars,
  352. });
  353. },
  354. externalContext: overrides?.externalContext,
  355. };
  356. }
  357. // --- Books ---
  358. app.get("/api/v1/books", async (c) => {
  359. const bookIds = await state.listBooks();
  360. const books = await Promise.all(
  361. bookIds.map(async (id) => {
  362. const book = await state.loadBookConfig(id);
  363. const nextChapter = await state.getNextChapterNumber(id);
  364. return { ...book, chaptersWritten: nextChapter - 1 };
  365. }),
  366. );
  367. return c.json({ books });
  368. });
  369. app.get("/api/v1/books/:id", async (c) => {
  370. const id = c.req.param("id");
  371. try {
  372. const book = await state.loadBookConfig(id);
  373. const chapters = await state.loadChapterIndex(id);
  374. const nextChapter = await state.getNextChapterNumber(id);
  375. return c.json({ book, chapters, nextChapter });
  376. } catch {
  377. return c.json({ error: `Book "${id}" not found` }, 404);
  378. }
  379. });
  380. // --- Genres ---
  381. app.get("/api/v1/genres", async (c) => {
  382. const { listAvailableGenres, readGenreProfile } = await import("@actalk/inkos-core");
  383. const rawGenres = await listAvailableGenres(root);
  384. const genres = await Promise.all(
  385. rawGenres.map(async (g) => {
  386. try {
  387. const { profile } = await readGenreProfile(root, g.id);
  388. return { ...g, language: profile.language ?? "zh" };
  389. } catch {
  390. return { ...g, language: "zh" };
  391. }
  392. }),
  393. );
  394. return c.json({ genres });
  395. });
  396. // --- Book Create ---
  397. app.post("/api/v1/books/create", async (c) => {
  398. const body = await c.req.json<{
  399. title: string;
  400. genre: string;
  401. language?: string;
  402. platform?: string;
  403. chapterWordCount?: number;
  404. targetChapters?: number;
  405. }>();
  406. const now = new Date().toISOString();
  407. const bookConfig = buildStudioBookConfig(body, now);
  408. const bookId = bookConfig.id;
  409. const bookDir = state.bookDir(bookId);
  410. try {
  411. await access(join(bookDir, "book.json"));
  412. await access(join(bookDir, "story", "story_bible.md"));
  413. return c.json({ error: `Book "${bookId}" already exists` }, 409);
  414. } catch {
  415. // The target book is not fully initialized yet, so creation can continue.
  416. }
  417. broadcast("book:creating", { bookId, title: body.title });
  418. bookCreateStatus.set(bookId, { status: "creating" });
  419. const pipeline = new PipelineRunner(await buildPipelineConfig());
  420. const tools = createInteractionToolsFromDeps(pipeline, state);
  421. processProjectInteractionRequest({
  422. projectRoot: root,
  423. request: {
  424. intent: "create_book",
  425. title: body.title,
  426. genre: body.genre,
  427. language: body.language === "en" ? "en" : body.language === "zh" ? "zh" : undefined,
  428. platform: body.platform,
  429. chapterWordCount: body.chapterWordCount,
  430. targetChapters: body.targetChapters,
  431. },
  432. tools,
  433. }).then(
  434. (result: {
  435. readonly session: { readonly activeBookId?: string };
  436. readonly details?: Readonly<Record<string, unknown>>;
  437. }) => {
  438. const createdBookId = (result.details?.bookId as string | undefined) ?? result.session.activeBookId ?? bookId;
  439. bookCreateStatus.delete(createdBookId);
  440. broadcast("book:created", { bookId: createdBookId });
  441. },
  442. (e: unknown) => {
  443. const error = e instanceof Error ? e.message : String(e);
  444. bookCreateStatus.set(bookId, { status: "error", error });
  445. broadcast("book:error", { bookId, error });
  446. },
  447. );
  448. return c.json({ status: "creating", bookId });
  449. });
  450. app.get("/api/v1/books/:id/create-status", async (c) => {
  451. const id = c.req.param("id");
  452. const status = bookCreateStatus.get(id);
  453. if (!status) {
  454. return c.json({ status: "missing" }, 404);
  455. }
  456. return c.json(status);
  457. });
  458. // --- Chapters ---
  459. app.get("/api/v1/books/:id/chapters/:num", async (c) => {
  460. const id = c.req.param("id");
  461. const num = parseInt(c.req.param("num"), 10);
  462. const bookDir = state.bookDir(id);
  463. const chaptersDir = join(bookDir, "chapters");
  464. try {
  465. const files = await readdir(chaptersDir);
  466. const paddedNum = String(num).padStart(4, "0");
  467. const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  468. if (!match) return c.json({ error: "Chapter not found" }, 404);
  469. const content = await readFile(join(chaptersDir, match), "utf-8");
  470. return c.json({ chapterNumber: num, filename: match, content });
  471. } catch {
  472. return c.json({ error: "Chapter not found" }, 404);
  473. }
  474. });
  475. // --- Chapter Save ---
  476. app.put("/api/v1/books/:id/chapters/:num", async (c) => {
  477. const id = c.req.param("id");
  478. const num = parseInt(c.req.param("num"), 10);
  479. const bookDir = state.bookDir(id);
  480. const chaptersDir = join(bookDir, "chapters");
  481. const { content } = await c.req.json<{ content: string }>();
  482. try {
  483. const files = await readdir(chaptersDir);
  484. const paddedNum = String(num).padStart(4, "0");
  485. const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  486. if (!match) return c.json({ error: "Chapter not found" }, 404);
  487. const { writeFile: writeFileFs } = await import("node:fs/promises");
  488. await writeFileFs(join(chaptersDir, match), content, "utf-8");
  489. return c.json({ ok: true, chapterNumber: num });
  490. } catch (e) {
  491. return c.json({ error: String(e) }, 500);
  492. }
  493. });
  494. // --- Truth files ---
  495. const TRUTH_FILES = [
  496. "author_intent.md", "current_focus.md",
  497. "story_bible.md", "volume_outline.md", "current_state.md",
  498. "particle_ledger.md", "pending_hooks.md", "chapter_summaries.md",
  499. "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
  500. "style_guide.md", "parent_canon.md", "fanfic_canon.md", "book_rules.md",
  501. ];
  502. app.get("/api/v1/books/:id/truth/:file", async (c) => {
  503. const id = c.req.param("id");
  504. const file = c.req.param("file");
  505. if (!TRUTH_FILES.includes(file)) {
  506. return c.json({ error: "Invalid truth file" }, 400);
  507. }
  508. const bookDir = state.bookDir(id);
  509. try {
  510. const content = await readFile(join(bookDir, "story", file), "utf-8");
  511. return c.json({ file, content });
  512. } catch {
  513. return c.json({ file, content: null });
  514. }
  515. });
  516. // --- Analytics ---
  517. app.get("/api/v1/books/:id/analytics", async (c) => {
  518. const id = c.req.param("id");
  519. try {
  520. const chapters = await state.loadChapterIndex(id);
  521. return c.json(computeAnalytics(id, chapters));
  522. } catch {
  523. return c.json({ error: `Book "${id}" not found` }, 404);
  524. }
  525. });
  526. // --- Actions ---
  527. app.post("/api/v1/books/:id/write-next", async (c) => {
  528. const id = c.req.param("id");
  529. const body = await c.req.json<{ wordCount?: number }>().catch(() => ({ wordCount: undefined }));
  530. broadcast("write:start", { bookId: id });
  531. // Fire and forget — progress/completion/errors pushed via SSE
  532. const pipeline = new PipelineRunner(await buildPipelineConfig());
  533. pipeline.writeNextChapter(id, body.wordCount).then(
  534. (result) => {
  535. broadcast("write:complete", { bookId: id, chapterNumber: result.chapterNumber, status: result.status, title: result.title, wordCount: result.wordCount });
  536. },
  537. (e) => {
  538. broadcast("write:error", { bookId: id, error: e instanceof Error ? e.message : String(e) });
  539. },
  540. );
  541. return c.json({ status: "writing", bookId: id });
  542. });
  543. app.post("/api/v1/books/:id/draft", async (c) => {
  544. const id = c.req.param("id");
  545. const body = await c.req.json<{ wordCount?: number; context?: string }>().catch(() => ({ wordCount: undefined, context: undefined }));
  546. broadcast("draft:start", { bookId: id });
  547. const pipeline = new PipelineRunner(await buildPipelineConfig());
  548. pipeline.writeDraft(id, body.context, body.wordCount).then(
  549. (result) => {
  550. broadcast("draft:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount });
  551. },
  552. (e) => {
  553. broadcast("draft:error", { bookId: id, error: e instanceof Error ? e.message : String(e) });
  554. },
  555. );
  556. return c.json({ status: "drafting", bookId: id });
  557. });
  558. app.post("/api/v1/books/:id/chapters/:num/approve", async (c) => {
  559. const id = c.req.param("id");
  560. const num = parseInt(c.req.param("num"), 10);
  561. try {
  562. const index = await state.loadChapterIndex(id);
  563. const updated = index.map((ch) =>
  564. ch.number === num ? { ...ch, status: "approved" as const } : ch,
  565. );
  566. await state.saveChapterIndex(id, updated);
  567. return c.json({ ok: true, chapterNumber: num, status: "approved" });
  568. } catch (e) {
  569. return c.json({ error: String(e) }, 500);
  570. }
  571. });
  572. app.post("/api/v1/books/:id/chapters/:num/reject", async (c) => {
  573. const id = c.req.param("id");
  574. const num = parseInt(c.req.param("num"), 10);
  575. try {
  576. const index = await state.loadChapterIndex(id);
  577. const target = index.find((ch) => ch.number === num);
  578. if (!target) {
  579. return c.json({ error: `Chapter ${num} not found` }, 404);
  580. }
  581. const rollbackTarget = num - 1;
  582. const discarded = await state.rollbackToChapter(id, rollbackTarget);
  583. return c.json({
  584. ok: true,
  585. chapterNumber: num,
  586. status: "rejected",
  587. rolledBackTo: rollbackTarget,
  588. discarded,
  589. });
  590. } catch (e) {
  591. return c.json({ error: String(e) }, 500);
  592. }
  593. });
  594. // --- SSE ---
  595. app.get("/api/v1/events", (c) => {
  596. return streamSSE(c, async (stream) => {
  597. const handler: EventHandler = (event, data) => {
  598. stream.writeSSE({ event, data: JSON.stringify(data) });
  599. };
  600. subscribers.add(handler);
  601. // Keep alive
  602. const keepAlive = setInterval(() => {
  603. stream.writeSSE({ event: "ping", data: "" });
  604. }, 30000);
  605. stream.onAbort(() => {
  606. subscribers.delete(handler);
  607. clearInterval(keepAlive);
  608. });
  609. // Block until aborted
  610. await new Promise(() => {});
  611. });
  612. });
  613. // --- Model discovery ---
  614. app.get("/api/v1/services", async (c) => {
  615. const secrets = await loadSecrets(root);
  616. const SERVICE_KEYS = [
  617. "openai", "anthropic", "deepseek", "moonshot", "minimax",
  618. "bailian", "zhipu", "siliconflow", "ppio", "openrouter", "ollama",
  619. ];
  620. // Fast: only check connection status from secrets, no external API calls
  621. const services = SERVICE_KEYS.map((key) => {
  622. const preset = resolveServicePreset(key);
  623. return {
  624. service: key,
  625. label: preset?.label ?? key,
  626. connected: Boolean(secrets.services[key]?.apiKey),
  627. };
  628. });
  629. // Add custom services from inkos.json
  630. try {
  631. const config = await loadRawConfig(root);
  632. for (const svc of normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services)) {
  633. if (svc.service === "custom") {
  634. const secretKey = `custom:${svc.name}`;
  635. services.push({
  636. service: secretKey,
  637. label: svc.name ?? "Custom",
  638. connected: Boolean(secrets.services[secretKey]?.apiKey),
  639. });
  640. }
  641. }
  642. } catch { /* no config file */ }
  643. return c.json({ services });
  644. });
  645. app.get("/api/v1/services/config", async (c) => {
  646. const config = await loadRawConfig(root);
  647. const llm = (config.llm as Record<string, unknown> | undefined) ?? {};
  648. const services = normalizeServiceConfig(llm.services);
  649. const envConfig = await readEnvConfigStatus(root);
  650. return c.json({
  651. services,
  652. defaultModel: llm.defaultModel ?? null,
  653. configSource: normalizeConfigSource(llm.configSource),
  654. envConfig,
  655. });
  656. });
  657. app.put("/api/v1/services/config", async (c) => {
  658. const body = await c.req.json<{ services?: unknown; defaultModel?: string; configSource?: LLMConfigSource }>();
  659. const config = await loadRawConfig(root);
  660. config.llm = config.llm ?? {};
  661. const llm = config.llm as Record<string, unknown>;
  662. if (body.services !== undefined) {
  663. const existingServices = normalizeServiceConfig(llm.services);
  664. const incomingServices = normalizeServiceConfig(body.services);
  665. llm.services = mergeServiceConfig(existingServices, incomingServices);
  666. }
  667. if (body.defaultModel !== undefined) {
  668. llm.defaultModel = body.defaultModel;
  669. }
  670. if (body.configSource !== undefined) {
  671. llm.configSource = normalizeConfigSource(body.configSource);
  672. }
  673. await saveRawConfig(root, config);
  674. return c.json({ ok: true });
  675. });
  676. app.post("/api/v1/services/:service/test", async (c) => {
  677. const service = c.req.param("service");
  678. const { apiKey, baseUrl, apiFormat, stream } = await c.req.json<{
  679. apiKey: string;
  680. baseUrl?: string;
  681. apiFormat?: "chat" | "responses";
  682. stream?: boolean;
  683. }>();
  684. if (!apiKey?.trim()) {
  685. return c.json({ ok: false, error: "API Key 不能为空" }, 400);
  686. }
  687. const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service, baseUrl);
  688. if (!resolvedBaseUrl) {
  689. return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
  690. }
  691. // Call the real /models API — no fallback
  692. const modelsUrl = resolvedBaseUrl.replace(/\/$/, "") + "/models";
  693. try {
  694. const res = await fetch(modelsUrl, {
  695. headers: { Authorization: `Bearer ${apiKey.trim()}` },
  696. signal: AbortSignal.timeout(10_000),
  697. });
  698. if (!res.ok) {
  699. const body = await res.text().catch(() => "");
  700. if (res.status === 401 || res.status === 403) {
  701. return c.json({ ok: false, error: "API Key 无效,请检查后重试" }, 400);
  702. }
  703. return c.json({ ok: false, error: `服务商返回 ${res.status}: ${body.slice(0, 200)}` }, 400);
  704. }
  705. const json = await res.json() as { data?: Array<{ id: string; owned_by?: string }> };
  706. const models = (json.data ?? []).map((m) => ({ id: m.id, name: m.id }));
  707. if (models.length === 0) {
  708. return c.json({ ok: false, error: "连接成功但未返回可用模型" }, 400);
  709. }
  710. const rawConfig = await loadRawConfig(root).catch(() => ({} as Record<string, unknown>));
  711. const preferredModel = typeof (rawConfig.llm as Record<string, unknown> | undefined)?.defaultModel === "string"
  712. ? String((rawConfig.llm as Record<string, unknown>).defaultModel)
  713. : undefined;
  714. const sampleModel = models.find((m) => m.id === preferredModel)?.id
  715. ?? models.find((m) => m.id === "gpt-5.4")?.id
  716. ?? models[0]?.id;
  717. if (sampleModel) {
  718. const client = createLLMClient({
  719. provider: service === "anthropic" ? "anthropic" : "openai",
  720. service: isCustomServiceId(service) ? "custom" : service,
  721. configSource: "studio",
  722. baseUrl: resolvedBaseUrl,
  723. apiKey: apiKey.trim(),
  724. model: sampleModel,
  725. temperature: 0.7,
  726. maxTokens: 64,
  727. thinkingBudget: 0,
  728. apiFormat: apiFormat ?? "chat",
  729. stream: stream ?? true,
  730. } as ProjectConfig["llm"]);
  731. try {
  732. await chatCompletion(
  733. client,
  734. sampleModel,
  735. [{ role: "user", content: "ping" }],
  736. { maxTokens: 5 },
  737. );
  738. } catch (error) {
  739. const message = error instanceof Error ? error.message : String(error);
  740. return c.json({ ok: false, error: `模型列表可读,但生成测试失败:${message}` }, 400);
  741. }
  742. }
  743. return c.json({ ok: true, modelCount: models.length, models: models.slice(0, 50) });
  744. } catch (err: any) {
  745. if (err?.name === "TimeoutError" || err?.name === "AbortError") {
  746. return c.json({ ok: false, error: `连接超时:无法访问 ${modelsUrl}` }, 400);
  747. }
  748. return c.json({ ok: false, error: `连接失败: ${err?.message ?? String(err)}` }, 400);
  749. }
  750. });
  751. app.put("/api/v1/services/:service/secret", async (c) => {
  752. const service = c.req.param("service");
  753. const { apiKey } = await c.req.json<{ apiKey: string }>();
  754. const secrets = await loadSecrets(root);
  755. if (apiKey?.trim()) {
  756. secrets.services[service] = { apiKey: apiKey.trim() };
  757. } else {
  758. delete secrets.services[service];
  759. }
  760. await saveSecrets(root, secrets);
  761. return c.json({ ok: true });
  762. });
  763. app.get("/api/v1/services/:service/models", async (c) => {
  764. const service = c.req.param("service");
  765. const apiKey = c.req.query("apiKey") || await getServiceApiKey(root, service);
  766. // No key = no models (don't fallback to built-in list)
  767. if (!apiKey) return c.json({ models: [] });
  768. const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
  769. if (!resolvedBaseUrl) return c.json({ models: [] });
  770. // Call real API only
  771. let models: Array<{ id: string; name: string }> = [];
  772. try {
  773. const modelsUrl = resolvedBaseUrl.replace(/\/$/, "") + "/models";
  774. const res = await fetch(modelsUrl, {
  775. headers: { Authorization: `Bearer ${apiKey}` },
  776. signal: AbortSignal.timeout(10_000),
  777. });
  778. if (res.ok) {
  779. const json = await res.json() as { data?: Array<{ id: string }> };
  780. models = (json.data ?? []).map((m) => ({ id: m.id, name: m.id }));
  781. }
  782. } catch { /* timeout or network error — return empty */ }
  783. return c.json({ models });
  784. });
  785. // --- Project info ---
  786. app.get("/api/v1/project", async (c) => {
  787. const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
  788. // Check if language was explicitly set in inkos.json (not just the schema default)
  789. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
  790. const languageExplicit = "language" in raw && raw.language !== "";
  791. return c.json({
  792. name: currentConfig.name,
  793. language: currentConfig.language,
  794. languageExplicit,
  795. model: currentConfig.llm.model,
  796. provider: currentConfig.llm.provider,
  797. baseUrl: currentConfig.llm.baseUrl,
  798. stream: currentConfig.llm.stream,
  799. temperature: currentConfig.llm.temperature,
  800. maxTokens: currentConfig.llm.maxTokens,
  801. });
  802. });
  803. // --- Config editing ---
  804. app.put("/api/v1/project", async (c) => {
  805. const updates = await c.req.json<Record<string, unknown>>();
  806. const configPath = join(root, "inkos.json");
  807. try {
  808. const raw = await readFile(configPath, "utf-8");
  809. const existing = JSON.parse(raw);
  810. // Merge LLM settings
  811. if (updates.temperature !== undefined) {
  812. existing.llm.temperature = updates.temperature;
  813. }
  814. if (updates.maxTokens !== undefined) {
  815. existing.llm.maxTokens = updates.maxTokens;
  816. }
  817. if (updates.stream !== undefined) {
  818. existing.llm.stream = updates.stream;
  819. }
  820. if (updates.language === "zh" || updates.language === "en") {
  821. existing.language = updates.language;
  822. }
  823. const { writeFile: writeFileFs } = await import("node:fs/promises");
  824. await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
  825. return c.json({ ok: true });
  826. } catch (e) {
  827. return c.json({ error: String(e) }, 500);
  828. }
  829. });
  830. // --- Truth files browser ---
  831. app.get("/api/v1/books/:id/truth", async (c) => {
  832. const id = c.req.param("id");
  833. const bookDir = state.bookDir(id);
  834. const storyDir = join(bookDir, "story");
  835. try {
  836. const files = await readdir(storyDir);
  837. const mdFiles = files.filter((f) => f.endsWith(".md") || f.endsWith(".json"));
  838. const result = await Promise.all(
  839. mdFiles.map(async (f) => {
  840. const content = await readFile(join(storyDir, f), "utf-8");
  841. return { name: f, size: content.length, preview: content.slice(0, 200) };
  842. }),
  843. );
  844. return c.json({ files: result });
  845. } catch {
  846. return c.json({ files: [] });
  847. }
  848. });
  849. // --- Daemon control ---
  850. let schedulerInstance: import("@actalk/inkos-core").Scheduler | null = null;
  851. app.get("/api/v1/daemon", (c) => {
  852. return c.json({
  853. running: schedulerInstance?.isRunning ?? false,
  854. });
  855. });
  856. app.post("/api/v1/daemon/start", async (c) => {
  857. if (schedulerInstance?.isRunning) {
  858. return c.json({ error: "Daemon already running" }, 400);
  859. }
  860. try {
  861. const { Scheduler } = await import("@actalk/inkos-core");
  862. const currentConfig = await loadCurrentProjectConfig();
  863. const scheduler = new Scheduler({
  864. ...(await buildPipelineConfig()),
  865. radarCron: currentConfig.daemon.schedule.radarCron,
  866. writeCron: currentConfig.daemon.schedule.writeCron,
  867. maxConcurrentBooks: currentConfig.daemon.maxConcurrentBooks,
  868. chaptersPerCycle: currentConfig.daemon.chaptersPerCycle,
  869. retryDelayMs: currentConfig.daemon.retryDelayMs,
  870. cooldownAfterChapterMs: currentConfig.daemon.cooldownAfterChapterMs,
  871. maxChaptersPerDay: currentConfig.daemon.maxChaptersPerDay,
  872. onChapterComplete: (bookId, chapter, status) => {
  873. broadcast("daemon:chapter", { bookId, chapter, status });
  874. },
  875. onError: (bookId, error) => {
  876. broadcast("daemon:error", { bookId, error: error.message });
  877. },
  878. });
  879. schedulerInstance = scheduler;
  880. broadcast("daemon:started", {});
  881. void scheduler.start().catch((e) => {
  882. const error = e instanceof Error ? e : new Error(String(e));
  883. if (schedulerInstance === scheduler) {
  884. scheduler.stop();
  885. schedulerInstance = null;
  886. broadcast("daemon:stopped", {});
  887. }
  888. broadcast("daemon:error", { bookId: "scheduler", error: error.message });
  889. });
  890. return c.json({ ok: true, running: true });
  891. } catch (e) {
  892. return c.json({ error: String(e) }, 500);
  893. }
  894. });
  895. app.post("/api/v1/daemon/stop", (c) => {
  896. if (!schedulerInstance?.isRunning) {
  897. return c.json({ error: "Daemon not running" }, 400);
  898. }
  899. schedulerInstance.stop();
  900. schedulerInstance = null;
  901. broadcast("daemon:stopped", {});
  902. return c.json({ ok: true, running: false });
  903. });
  904. // --- Logs ---
  905. app.get("/api/v1/logs", async (c) => {
  906. const logPath = join(root, "inkos.log");
  907. try {
  908. const content = await readFile(logPath, "utf-8");
  909. const lines = content.trim().split("\n").slice(-100);
  910. const entries = lines.map((line) => {
  911. try { return JSON.parse(line); } catch { return { message: line }; }
  912. });
  913. return c.json({ entries });
  914. } catch {
  915. return c.json({ entries: [] });
  916. }
  917. });
  918. // --- Agent chat ---
  919. app.get("/api/v1/interaction/session", async (c) => {
  920. const session = await loadProjectSession(root);
  921. const activeBookId = await resolveSessionActiveBook(root, session);
  922. return c.json({
  923. session: activeBookId && session.activeBookId !== activeBookId
  924. ? { ...session, activeBookId }
  925. : session,
  926. activeBookId,
  927. });
  928. });
  929. // -- Per-book session endpoints --
  930. app.get("/api/v1/sessions", async (c) => {
  931. const bookId = c.req.query("bookId");
  932. const sessions = await listBookSessions(root, bookId === undefined ? null : bookId === "null" ? null : bookId);
  933. return c.json({ sessions: sessions.map((s) => ({
  934. sessionId: s.sessionId,
  935. bookId: s.bookId,
  936. messageCount: s.messages.length,
  937. createdAt: s.createdAt,
  938. updatedAt: s.updatedAt,
  939. })) });
  940. });
  941. app.get("/api/v1/sessions/:sessionId", async (c) => {
  942. const session = await loadBookSession(root, c.req.param("sessionId"));
  943. if (!session) return c.json({ error: "Session not found" }, 404);
  944. return c.json({ session });
  945. });
  946. app.post("/api/v1/sessions", async (c) => {
  947. const body = await c.req.json<{ bookId?: string | null }>().catch(() => ({}));
  948. const bookId = (body as { bookId?: string | null }).bookId ?? null;
  949. const session = await findOrCreateBookSession(root, bookId);
  950. return c.json({ session });
  951. });
  952. app.post("/api/v1/agent", async (c) => {
  953. const { instruction, activeBookId, sessionId: reqSessionId, model: reqModel, service: reqService } = await c.req.json<{
  954. instruction: string;
  955. activeBookId?: string;
  956. sessionId?: string;
  957. model?: string;
  958. service?: string;
  959. }>();
  960. const sessionId = reqSessionId;
  961. if (!instruction?.trim()) {
  962. return c.json({ error: "No instruction provided" }, 400);
  963. }
  964. broadcast("agent:start", { instruction, activeBookId });
  965. try {
  966. // Load config + create LLM client (pipeline created after model resolution)
  967. const config = await loadCurrentProjectConfig({ requireApiKey: false });
  968. const client = createLLMClient(config.llm);
  969. // Resolve or create BookSession for history
  970. let bookSession: BookSession;
  971. if (sessionId) {
  972. bookSession =
  973. (await loadBookSession(root, sessionId)) ??
  974. (await findOrCreateBookSession(root, activeBookId ?? null));
  975. } else {
  976. bookSession = await findOrCreateBookSession(root, activeBookId ?? null);
  977. }
  978. // Build initial message context from persisted session
  979. const initialMessages = bookSession.messages
  980. .filter((m) => m.role === "user" || m.role === "assistant")
  981. .map((m) => ({ role: m.role as "user" | "assistant", content: m.content }));
  982. // Resolve model — multi-service resolution
  983. let resolvedModel: ResolvedModel["model"] | undefined;
  984. let resolvedApiKey: string | undefined;
  985. if (reqService && reqModel) {
  986. // 1. Frontend explicitly selected a service+model — fail loudly if no key
  987. try {
  988. const configuredEntry = await resolveConfiguredServiceEntry(root, reqService);
  989. const resolved = await resolveServiceModel(
  990. reqService,
  991. reqModel,
  992. root,
  993. await resolveConfiguredServiceBaseUrl(root, reqService),
  994. configuredEntry?.apiFormat,
  995. );
  996. resolvedModel = resolved.model;
  997. resolvedApiKey = resolved.apiKey;
  998. } catch (e: any) {
  999. const msg = e?.message ?? String(e);
  1000. if (/API key/i.test(msg)) {
  1001. return c.json({
  1002. error: `请先为 ${reqService} 配置 API Key`,
  1003. response: `请先在模型配置中为 ${reqService} 填写 API Key,然后再试。`,
  1004. }, 400);
  1005. }
  1006. throw e;
  1007. }
  1008. }
  1009. if (!resolvedModel) {
  1010. // 2. Try defaultModel from new config format
  1011. const rawConfig = config.llm as unknown as Record<string, unknown>;
  1012. const defaultModel = rawConfig.defaultModel as string | undefined;
  1013. const servicesArr = normalizeServiceConfig(rawConfig.services);
  1014. const firstService = servicesArr[0];
  1015. if (firstService?.service && defaultModel) {
  1016. try {
  1017. const resolved = await resolveServiceModel(
  1018. serviceConfigKey(firstService),
  1019. defaultModel,
  1020. root,
  1021. firstService.baseUrl,
  1022. firstService.apiFormat,
  1023. );
  1024. resolvedModel = resolved.model;
  1025. resolvedApiKey = resolved.apiKey;
  1026. } catch { /* fall through */ }
  1027. }
  1028. }
  1029. if (!resolvedModel) {
  1030. // 3. Try first connected service from secrets
  1031. const secrets = await loadSecrets(root);
  1032. for (const [svcName, svcData] of Object.entries(secrets.services)) {
  1033. if (svcData?.apiKey) {
  1034. try {
  1035. const models = await listModelsForService(svcName, svcData.apiKey);
  1036. if (models.length > 0) {
  1037. const configuredEntry = await resolveConfiguredServiceEntry(root, svcName);
  1038. const resolved = await resolveServiceModel(
  1039. svcName,
  1040. models[0].id,
  1041. root,
  1042. await resolveConfiguredServiceBaseUrl(root, svcName),
  1043. configuredEntry?.apiFormat,
  1044. );
  1045. resolvedModel = resolved.model;
  1046. resolvedApiKey = resolved.apiKey;
  1047. break;
  1048. }
  1049. } catch { /* try next */ }
  1050. }
  1051. }
  1052. }
  1053. if (!resolvedModel) {
  1054. // 4. Legacy fallback: use createLLMClient
  1055. resolvedModel = client._piModel
  1056. ? client._piModel
  1057. : { provider: config.llm.provider ?? "anthropic", modelId: config.llm.model } as any;
  1058. resolvedApiKey = client._apiKey;
  1059. }
  1060. const model = resolvedModel!;
  1061. const agentApiKey = resolvedApiKey;
  1062. const configuredEntry = reqService ? await resolveConfiguredServiceEntry(root, reqService) : undefined;
  1063. // Create pipeline with resolved model (so sub_agent tools use the frontend-selected model)
  1064. // Don't spread config.llm — its baseUrl/provider belong to the old service.
  1065. // Let createLLMClient resolve baseUrl from the service preset.
  1066. const pipelineClient = (reqService && reqModel && resolvedApiKey)
  1067. ? createLLMClient({
  1068. ...config.llm,
  1069. service: configuredEntry?.service ?? reqService,
  1070. model: reqModel,
  1071. apiKey: resolvedApiKey,
  1072. ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
  1073. ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
  1074. baseUrl: configuredEntry?.baseUrl ?? "",
  1075. } as any)
  1076. : client;
  1077. const pipeline = new PipelineRunner(await buildPipelineConfig({
  1078. client: pipelineClient,
  1079. model: reqModel ?? config.llm.model,
  1080. currentConfig: config,
  1081. }));
  1082. // Run pi-agent session
  1083. const collectedToolExecs: CollectedToolExec[] = [];
  1084. const result = await runAgentSession(
  1085. {
  1086. model,
  1087. apiKey: agentApiKey,
  1088. pipeline,
  1089. projectRoot: root,
  1090. bookId: activeBookId ?? null,
  1091. sessionId: bookSession.sessionId,
  1092. language: config.language ?? "zh",
  1093. onEvent: (event) => {
  1094. if (event.type === "message_update") {
  1095. const ame = event.assistantMessageEvent;
  1096. if (ame.type === "text_delta") {
  1097. broadcast("draft:delta", { text: ame.delta });
  1098. } else if (ame.type === "thinking_delta") {
  1099. broadcast("thinking:delta", { text: (ame as any).delta });
  1100. } else if (ame.type === "thinking_start") {
  1101. broadcast("thinking:start", {});
  1102. } else if (ame.type === "thinking_end") {
  1103. broadcast("thinking:end", {});
  1104. }
  1105. }
  1106. if (event.type === "tool_execution_start") {
  1107. const args = event.args as Record<string, unknown> | undefined;
  1108. const agent = event.toolName === "sub_agent" ? (args?.agent as string | undefined) : undefined;
  1109. const stages = agent ? (PIPELINE_STAGES[agent] ?? []) : [];
  1110. collectedToolExecs.push({
  1111. id: event.toolCallId,
  1112. tool: event.toolName,
  1113. agent,
  1114. label: resolveToolLabel(event.toolName, agent),
  1115. status: "running",
  1116. args,
  1117. stages: stages.length > 0
  1118. ? stages.map(l => ({ label: l, status: "pending" as const }))
  1119. : undefined,
  1120. startedAt: Date.now(),
  1121. });
  1122. broadcast("tool:start", {
  1123. id: event.toolCallId,
  1124. tool: event.toolName,
  1125. args,
  1126. stages,
  1127. });
  1128. }
  1129. if (event.type === "tool_execution_update") {
  1130. broadcast("tool:update", { tool: event.toolName, partialResult: event.partialResult });
  1131. }
  1132. if (event.type === "tool_execution_end") {
  1133. const exec = collectedToolExecs.find(t => t.id === event.toolCallId);
  1134. if (exec) {
  1135. exec.status = event.isError ? "error" : "completed";
  1136. exec.completedAt = Date.now();
  1137. exec.stages = exec.stages?.map(s => ({ ...s, status: "completed" as const }));
  1138. if (event.isError) exec.error = extractToolError(event.result);
  1139. else exec.result = summarizeResult(event.result);
  1140. }
  1141. broadcast("tool:end", {
  1142. id: event.toolCallId,
  1143. tool: event.toolName,
  1144. result: event.result,
  1145. isError: event.isError,
  1146. });
  1147. }
  1148. },
  1149. },
  1150. instruction,
  1151. initialMessages,
  1152. );
  1153. // Persist user + assistant messages to BookSession
  1154. bookSession = appendBookSessionMessage(bookSession, {
  1155. role: "user",
  1156. content: instruction,
  1157. timestamp: Date.now(),
  1158. });
  1159. if (result.responseText) {
  1160. const lastAssistant = result.messages?.filter((m: any) => m.role === "assistant").pop();
  1161. const thinking = lastAssistant?.thinking;
  1162. bookSession = appendBookSessionMessage(bookSession, {
  1163. role: "assistant",
  1164. content: result.responseText,
  1165. ...(thinking ? { thinking } : {}),
  1166. ...(collectedToolExecs.length > 0 ? { toolExecutions: collectedToolExecs } : {}),
  1167. timestamp: Date.now() + 1,
  1168. });
  1169. }
  1170. if (!result.responseText) {
  1171. try {
  1172. const fallbackClient = createLLMClient({
  1173. ...config.llm,
  1174. service: configuredEntry?.service ?? reqService ?? config.llm.service,
  1175. model: reqModel ?? config.llm.model,
  1176. apiKey: agentApiKey ?? config.llm.apiKey,
  1177. baseUrl: configuredEntry?.baseUrl ?? config.llm.baseUrl,
  1178. ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
  1179. ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
  1180. } as ProjectConfig["llm"]);
  1181. const fallback = await chatCompletion(
  1182. fallbackClient,
  1183. reqModel ?? config.llm.model,
  1184. [
  1185. { role: "system", content: buildAgentSystemPrompt(activeBookId ?? null, config.language ?? "zh") },
  1186. { role: "user", content: instruction },
  1187. ],
  1188. { maxTokens: 256 },
  1189. );
  1190. if (fallback.content?.trim()) {
  1191. bookSession = appendBookSessionMessage(bookSession, {
  1192. role: "assistant",
  1193. content: fallback.content,
  1194. timestamp: Date.now() + 1,
  1195. });
  1196. await persistBookSession(root, bookSession);
  1197. return c.json({
  1198. response: fallback.content,
  1199. session: { sessionId: bookSession.sessionId },
  1200. });
  1201. }
  1202. } catch {
  1203. // fall through to probe-based diagnosis below
  1204. }
  1205. try {
  1206. const probeClient = createLLMClient({
  1207. ...config.llm,
  1208. service: configuredEntry?.service ?? reqService ?? config.llm.service,
  1209. model: reqModel ?? config.llm.model,
  1210. apiKey: agentApiKey ?? config.llm.apiKey,
  1211. baseUrl: configuredEntry?.baseUrl ?? config.llm.baseUrl,
  1212. ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
  1213. ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
  1214. } as ProjectConfig["llm"]);
  1215. await chatCompletion(
  1216. probeClient,
  1217. reqModel ?? config.llm.model,
  1218. [{ role: "user", content: "ping" }],
  1219. { maxTokens: 5 },
  1220. );
  1221. } catch (probeError) {
  1222. const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
  1223. return c.json({
  1224. error: { code: "AGENT_EMPTY_RESPONSE", message: probeMessage },
  1225. response: probeMessage,
  1226. }, 502);
  1227. }
  1228. const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
  1229. return c.json({
  1230. error: { code: "AGENT_EMPTY_RESPONSE", message: emptyMessage },
  1231. response: emptyMessage,
  1232. }, 502);
  1233. }
  1234. await persistBookSession(root, bookSession);
  1235. broadcast("agent:complete", { instruction, activeBookId });
  1236. return c.json({
  1237. response: result.responseText,
  1238. session: { sessionId: bookSession.sessionId },
  1239. });
  1240. } catch (e) {
  1241. const msg = e instanceof Error ? e.message : String(e);
  1242. broadcast("agent:error", { instruction, activeBookId, error: msg });
  1243. // Agent busy — return 429 with user-friendly message
  1244. if (/already processing|prompt.*queue/i.test(msg)) {
  1245. return c.json({
  1246. error: { code: "AGENT_BUSY", message: "正在处理中,请等待当前操作完成" },
  1247. response: "正在处理中,请等待当前操作完成后再发送。",
  1248. }, 429);
  1249. }
  1250. return c.json(
  1251. { error: { code: "AGENT_ERROR", message: msg } },
  1252. 500,
  1253. );
  1254. }
  1255. });
  1256. // --- Language setup ---
  1257. app.post("/api/v1/project/language", async (c) => {
  1258. const { language } = await c.req.json<{ language: "zh" | "en" }>();
  1259. const configPath = join(root, "inkos.json");
  1260. try {
  1261. const raw = await readFile(configPath, "utf-8");
  1262. const existing = JSON.parse(raw);
  1263. existing.language = language;
  1264. const { writeFile: writeFileFs } = await import("node:fs/promises");
  1265. await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
  1266. return c.json({ ok: true, language });
  1267. } catch (e) {
  1268. return c.json({ error: String(e) }, 500);
  1269. }
  1270. });
  1271. // --- Audit ---
  1272. app.post("/api/v1/books/:id/audit/:chapter", async (c) => {
  1273. const id = c.req.param("id");
  1274. const chapterNum = parseInt(c.req.param("chapter"), 10);
  1275. const bookDir = state.bookDir(id);
  1276. broadcast("audit:start", { bookId: id, chapter: chapterNum });
  1277. try {
  1278. const book = await state.loadBookConfig(id);
  1279. const chaptersDir = join(bookDir, "chapters");
  1280. const files = await readdir(chaptersDir);
  1281. const paddedNum = String(chapterNum).padStart(4, "0");
  1282. const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  1283. if (!match) return c.json({ error: "Chapter not found" }, 404);
  1284. const content = await readFile(join(chaptersDir, match), "utf-8");
  1285. const currentConfig = await loadCurrentProjectConfig();
  1286. const { ContinuityAuditor } = await import("@actalk/inkos-core");
  1287. const auditor = new ContinuityAuditor({
  1288. client: createLLMClient(currentConfig.llm),
  1289. model: currentConfig.llm.model,
  1290. projectRoot: root,
  1291. bookId: id,
  1292. });
  1293. const result = await auditor.auditChapter(bookDir, content, chapterNum, book.genre);
  1294. broadcast("audit:complete", { bookId: id, chapter: chapterNum, passed: result.passed });
  1295. return c.json(result);
  1296. } catch (e) {
  1297. broadcast("audit:error", { bookId: id, error: String(e) });
  1298. return c.json({ error: String(e) }, 500);
  1299. }
  1300. });
  1301. // --- Revise ---
  1302. app.post("/api/v1/books/:id/revise/:chapter", async (c) => {
  1303. const id = c.req.param("id");
  1304. const chapterNum = parseInt(c.req.param("chapter"), 10);
  1305. const bookDir = state.bookDir(id);
  1306. const body = await c.req
  1307. .json<{ mode?: string; brief?: string }>()
  1308. .catch(() => ({ mode: "spot-fix", brief: undefined }));
  1309. broadcast("revise:start", { bookId: id, chapter: chapterNum });
  1310. try {
  1311. const book = await state.loadBookConfig(id);
  1312. const chaptersDir = join(bookDir, "chapters");
  1313. const files = await readdir(chaptersDir);
  1314. const paddedNum = String(chapterNum).padStart(4, "0");
  1315. const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  1316. if (!match) return c.json({ error: "Chapter not found" }, 404);
  1317. const pipeline = new PipelineRunner(await buildPipelineConfig({
  1318. externalContext: body.brief,
  1319. }));
  1320. const normalizedMode = body.mode ?? "spot-fix";
  1321. const result = await pipeline.reviseDraft(
  1322. id,
  1323. chapterNum,
  1324. normalizedMode as "polish" | "rewrite" | "rework" | "spot-fix" | "anti-detect",
  1325. );
  1326. broadcast("revise:complete", { bookId: id, chapter: chapterNum });
  1327. return c.json(result);
  1328. } catch (e) {
  1329. broadcast("revise:error", { bookId: id, error: String(e) });
  1330. return c.json({ error: String(e) }, 500);
  1331. }
  1332. });
  1333. // --- Export ---
  1334. app.get("/api/v1/books/:id/export", async (c) => {
  1335. const id = c.req.param("id");
  1336. const format = (c.req.query("format") ?? "txt") as string;
  1337. const approvedOnly = c.req.query("approvedOnly") === "true";
  1338. const bookDir = state.bookDir(id);
  1339. const chaptersDir = join(bookDir, "chapters");
  1340. try {
  1341. const book = await state.loadBookConfig(id);
  1342. const index = await state.loadChapterIndex(id);
  1343. const approvedNums = new Set(
  1344. approvedOnly ? index.filter((ch) => ch.status === "approved").map((ch) => ch.number) : [],
  1345. );
  1346. const files = await readdir(chaptersDir);
  1347. const mdFiles = files.filter((f) => f.endsWith(".md") && /^\d{4}/.test(f)).sort();
  1348. const filteredFiles = approvedOnly
  1349. ? mdFiles.filter((f) => approvedNums.has(parseInt(f.slice(0, 4), 10)))
  1350. : mdFiles;
  1351. const contents = await Promise.all(
  1352. filteredFiles.map((f) => readFile(join(chaptersDir, f), "utf-8")),
  1353. );
  1354. if (format === "epub") {
  1355. // Basic EPUB: XHTML container
  1356. const chapters = contents.map((content, i) => {
  1357. const title = content.match(/^#\s+(.+)$/m)?.[1] ?? `Chapter ${i + 1}`;
  1358. const html = content.split("\n").filter((l) => !l.startsWith("#")).map((l) => l.trim() ? `<p>${l}</p>` : "").join("\n");
  1359. return { title, html };
  1360. });
  1361. const toc = chapters.map((ch, i) => `<li><a href="#ch${i}">${ch.title}</a></li>`).join("\n");
  1362. const body = chapters.map((ch, i) => `<h2 id="ch${i}">${ch.title}</h2>\n${ch.html}`).join("\n<hr/>\n");
  1363. const epub = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${book.title}</title><style>body{font-family:serif;max-width:40em;margin:auto;padding:2em;line-height:1.8}h2{margin-top:3em}</style></head><body><h1>${book.title}</h1><nav><ol>${toc}</ol></nav><hr/>${body}</body></html>`;
  1364. return new Response(epub, {
  1365. headers: {
  1366. "Content-Type": "text/html; charset=utf-8",
  1367. "Content-Disposition": `attachment; filename="${id}.html"`,
  1368. },
  1369. });
  1370. }
  1371. if (format === "md") {
  1372. const body = contents.join("\n\n---\n\n");
  1373. return new Response(body, {
  1374. headers: {
  1375. "Content-Type": "text/markdown; charset=utf-8",
  1376. "Content-Disposition": `attachment; filename="${id}.md"`,
  1377. },
  1378. });
  1379. }
  1380. // Default: txt
  1381. const body = contents.join("\n\n");
  1382. return new Response(body, {
  1383. headers: {
  1384. "Content-Type": "text/plain; charset=utf-8",
  1385. "Content-Disposition": `attachment; filename="${id}.txt"`,
  1386. },
  1387. });
  1388. } catch {
  1389. return c.json({ error: "Export failed" }, 500);
  1390. }
  1391. });
  1392. // --- Export to file (save to project dir) ---
  1393. app.post("/api/v1/books/:id/export-save", async (c) => {
  1394. const id = c.req.param("id");
  1395. const { format, approvedOnly } = await c.req.json<{ format?: string; approvedOnly?: boolean }>().catch(() => ({ format: "txt", approvedOnly: false }));
  1396. const fmt = format ?? "txt";
  1397. try {
  1398. const pipeline = new PipelineRunner(await buildPipelineConfig());
  1399. const tools = createInteractionToolsFromDeps(pipeline, state);
  1400. const bookDir = state.bookDir(id);
  1401. const outputPath = join(bookDir, `${id}.${fmt === "epub" ? "epub" : fmt}`);
  1402. const result = await processProjectInteractionRequest({
  1403. projectRoot: root,
  1404. request: {
  1405. intent: "export_book",
  1406. bookId: id,
  1407. format: fmt as "txt" | "md" | "epub",
  1408. approvedOnly,
  1409. outputPath,
  1410. },
  1411. tools,
  1412. activeBookId: id,
  1413. });
  1414. return c.json({
  1415. ok: true,
  1416. path: (result.details?.outputPath as string | undefined) ?? outputPath,
  1417. format: fmt,
  1418. chapters: (result.details?.chaptersExported as number | undefined) ?? 0,
  1419. });
  1420. } catch (e) {
  1421. return c.json({ error: String(e) }, 500);
  1422. }
  1423. });
  1424. // --- Genre detail + copy ---
  1425. app.get("/api/v1/genres/:id", async (c) => {
  1426. const genreId = c.req.param("id");
  1427. try {
  1428. const { readGenreProfile } = await import("@actalk/inkos-core");
  1429. const { profile, body } = await readGenreProfile(root, genreId);
  1430. return c.json({ profile, body });
  1431. } catch (e) {
  1432. return c.json({ error: String(e) }, 404);
  1433. }
  1434. });
  1435. app.post("/api/v1/genres/:id/copy", async (c) => {
  1436. const genreId = c.req.param("id");
  1437. if (/[/\\\0]/.test(genreId) || genreId.includes("..")) {
  1438. throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${genreId}"`);
  1439. }
  1440. try {
  1441. const { getBuiltinGenresDir } = await import("@actalk/inkos-core");
  1442. const { mkdir: mkdirFs, copyFile } = await import("node:fs/promises");
  1443. const builtinDir = getBuiltinGenresDir();
  1444. const projectGenresDir = join(root, "genres");
  1445. await mkdirFs(projectGenresDir, { recursive: true });
  1446. await copyFile(join(builtinDir, `${genreId}.md`), join(projectGenresDir, `${genreId}.md`));
  1447. return c.json({ ok: true, path: `genres/${genreId}.md` });
  1448. } catch (e) {
  1449. return c.json({ error: String(e) }, 500);
  1450. }
  1451. });
  1452. // --- Model overrides ---
  1453. app.get("/api/v1/project/model-overrides", async (c) => {
  1454. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
  1455. return c.json({ overrides: raw.modelOverrides ?? {} });
  1456. });
  1457. app.put("/api/v1/project/model-overrides", async (c) => {
  1458. const { overrides } = await c.req.json<{ overrides: Record<string, unknown> }>();
  1459. const configPath = join(root, "inkos.json");
  1460. const raw = JSON.parse(await readFile(configPath, "utf-8"));
  1461. raw.modelOverrides = overrides;
  1462. const { writeFile: writeFileFs } = await import("node:fs/promises");
  1463. await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
  1464. return c.json({ ok: true });
  1465. });
  1466. // --- Notify channels ---
  1467. app.get("/api/v1/project/notify", async (c) => {
  1468. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
  1469. return c.json({ channels: raw.notify ?? [] });
  1470. });
  1471. app.put("/api/v1/project/notify", async (c) => {
  1472. const { channels } = await c.req.json<{ channels: unknown[] }>();
  1473. const configPath = join(root, "inkos.json");
  1474. const raw = JSON.parse(await readFile(configPath, "utf-8"));
  1475. raw.notify = channels;
  1476. const { writeFile: writeFileFs } = await import("node:fs/promises");
  1477. await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
  1478. return c.json({ ok: true });
  1479. });
  1480. // --- AIGC Detection ---
  1481. app.post("/api/v1/books/:id/detect/:chapter", async (c) => {
  1482. const id = c.req.param("id");
  1483. const chapterNum = parseInt(c.req.param("chapter"), 10);
  1484. const bookDir = state.bookDir(id);
  1485. try {
  1486. const chaptersDir = join(bookDir, "chapters");
  1487. const files = await readdir(chaptersDir);
  1488. const paddedNum = String(chapterNum).padStart(4, "0");
  1489. const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  1490. if (!match) return c.json({ error: "Chapter not found" }, 404);
  1491. const content = await readFile(join(chaptersDir, match), "utf-8");
  1492. const { analyzeAITells } = await import("@actalk/inkos-core");
  1493. const result = analyzeAITells(content);
  1494. return c.json({ chapterNumber: chapterNum, ...result });
  1495. } catch (e) {
  1496. return c.json({ error: String(e) }, 500);
  1497. }
  1498. });
  1499. // --- Truth file edit ---
  1500. app.put("/api/v1/books/:id/truth/:file", async (c) => {
  1501. const id = c.req.param("id");
  1502. const file = c.req.param("file");
  1503. if (!TRUTH_FILES.includes(file)) {
  1504. return c.json({ error: "Invalid truth file" }, 400);
  1505. }
  1506. const { content } = await c.req.json<{ content: string }>();
  1507. const bookDir = state.bookDir(id);
  1508. const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
  1509. await mkdirFs(join(bookDir, "story"), { recursive: true });
  1510. await writeFileFs(join(bookDir, "story", file), content, "utf-8");
  1511. return c.json({ ok: true });
  1512. });
  1513. // =============================================
  1514. // NEW ENDPOINTS — CLI parity
  1515. // =============================================
  1516. // --- Book Delete ---
  1517. app.delete("/api/v1/books/:id", async (c) => {
  1518. const id = c.req.param("id");
  1519. const bookDir = state.bookDir(id);
  1520. try {
  1521. const { rm } = await import("node:fs/promises");
  1522. await rm(bookDir, { recursive: true, force: true });
  1523. broadcast("book:deleted", { bookId: id });
  1524. return c.json({ ok: true, bookId: id });
  1525. } catch (e) {
  1526. return c.json({ error: String(e) }, 500);
  1527. }
  1528. });
  1529. // --- Book Update ---
  1530. app.put("/api/v1/books/:id", async (c) => {
  1531. const id = c.req.param("id");
  1532. const updates = await c.req.json<{
  1533. chapterWordCount?: number;
  1534. targetChapters?: number;
  1535. status?: string;
  1536. language?: string;
  1537. }>();
  1538. try {
  1539. const book = await state.loadBookConfig(id);
  1540. const updated = {
  1541. ...book,
  1542. ...(updates.chapterWordCount !== undefined ? { chapterWordCount: Number(updates.chapterWordCount) } : {}),
  1543. ...(updates.targetChapters !== undefined ? { targetChapters: Number(updates.targetChapters) } : {}),
  1544. ...(updates.status !== undefined ? { status: updates.status as typeof book.status } : {}),
  1545. ...(updates.language !== undefined ? { language: updates.language as "zh" | "en" } : {}),
  1546. updatedAt: new Date().toISOString(),
  1547. };
  1548. await state.saveBookConfig(id, updated);
  1549. return c.json({ ok: true, book: updated });
  1550. } catch (e) {
  1551. return c.json({ error: String(e) }, 500);
  1552. }
  1553. });
  1554. // --- Write Rewrite (specific chapter) ---
  1555. app.post("/api/v1/books/:id/rewrite/:chapter", async (c) => {
  1556. const id = c.req.param("id");
  1557. const chapterNum = parseInt(c.req.param("chapter"), 10);
  1558. const body: { brief?: string } = await c.req
  1559. .json<{ brief?: string }>()
  1560. .catch(() => ({}));
  1561. broadcast("rewrite:start", { bookId: id, chapter: chapterNum });
  1562. try {
  1563. const rollbackTarget = chapterNum - 1;
  1564. const discarded = await state.rollbackToChapter(id, rollbackTarget);
  1565. const pipeline = new PipelineRunner(await buildPipelineConfig({
  1566. externalContext: body.brief,
  1567. }));
  1568. pipeline.writeNextChapter(id).then(
  1569. (result) => broadcast("rewrite:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount }),
  1570. (e) => broadcast("rewrite:error", { bookId: id, error: e instanceof Error ? e.message : String(e) }),
  1571. );
  1572. return c.json({ status: "rewriting", bookId: id, chapter: chapterNum, rolledBackTo: rollbackTarget, discarded });
  1573. } catch (e) {
  1574. broadcast("rewrite:error", { bookId: id, error: String(e) });
  1575. return c.json({ error: String(e) }, 500);
  1576. }
  1577. });
  1578. app.post("/api/v1/books/:id/resync/:chapter", async (c) => {
  1579. const id = c.req.param("id");
  1580. const chapterNum = parseInt(c.req.param("chapter"), 10);
  1581. const body: { brief?: string } = await c.req
  1582. .json<{ brief?: string }>()
  1583. .catch(() => ({}));
  1584. try {
  1585. const pipeline = new PipelineRunner(await buildPipelineConfig({
  1586. externalContext: body.brief,
  1587. }));
  1588. const result = await pipeline.resyncChapterArtifacts(id, chapterNum);
  1589. return c.json(result);
  1590. } catch (e) {
  1591. return c.json({ error: String(e) }, 500);
  1592. }
  1593. });
  1594. // --- Detect All chapters ---
  1595. app.post("/api/v1/books/:id/detect-all", async (c) => {
  1596. const id = c.req.param("id");
  1597. const bookDir = state.bookDir(id);
  1598. try {
  1599. const chaptersDir = join(bookDir, "chapters");
  1600. const files = await readdir(chaptersDir);
  1601. const mdFiles = files.filter((f) => f.endsWith(".md") && /^\d{4}/.test(f)).sort();
  1602. const { analyzeAITells } = await import("@actalk/inkos-core");
  1603. const results = await Promise.all(
  1604. mdFiles.map(async (f) => {
  1605. const num = parseInt(f.slice(0, 4), 10);
  1606. const content = await readFile(join(chaptersDir, f), "utf-8");
  1607. const result = analyzeAITells(content);
  1608. return { chapterNumber: num, filename: f, ...result };
  1609. }),
  1610. );
  1611. return c.json({ bookId: id, results });
  1612. } catch (e) {
  1613. return c.json({ error: String(e) }, 500);
  1614. }
  1615. });
  1616. // --- Detect Stats ---
  1617. app.get("/api/v1/books/:id/detect/stats", async (c) => {
  1618. const id = c.req.param("id");
  1619. try {
  1620. const { loadDetectionHistory, analyzeDetectionInsights } = await import("@actalk/inkos-core");
  1621. const bookDir = state.bookDir(id);
  1622. const history = await loadDetectionHistory(bookDir);
  1623. const insights = analyzeDetectionInsights(history);
  1624. return c.json(insights);
  1625. } catch (e) {
  1626. return c.json({ error: String(e) }, 500);
  1627. }
  1628. });
  1629. // --- Genre Create ---
  1630. app.post("/api/v1/genres/create", async (c) => {
  1631. const body = await c.req.json<{
  1632. id: string; name: string; language?: string;
  1633. chapterTypes?: string[]; fatigueWords?: string[];
  1634. numericalSystem?: boolean; powerScaling?: boolean; eraResearch?: boolean;
  1635. pacingRule?: string; satisfactionTypes?: string[]; auditDimensions?: number[];
  1636. body?: string;
  1637. }>();
  1638. if (!body.id || !body.name) {
  1639. return c.json({ error: "id and name are required" }, 400);
  1640. }
  1641. if (/[/\\\0]/.test(body.id) || body.id.includes("..")) {
  1642. throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${body.id}"`);
  1643. }
  1644. const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
  1645. const genresDir = join(root, "genres");
  1646. await mkdirFs(genresDir, { recursive: true });
  1647. const frontmatter = [
  1648. "---",
  1649. `name: ${body.name}`,
  1650. `id: ${body.id}`,
  1651. `language: ${body.language ?? "zh"}`,
  1652. `chapterTypes: ${JSON.stringify(body.chapterTypes ?? [])}`,
  1653. `fatigueWords: ${JSON.stringify(body.fatigueWords ?? [])}`,
  1654. `numericalSystem: ${body.numericalSystem ?? false}`,
  1655. `powerScaling: ${body.powerScaling ?? false}`,
  1656. `eraResearch: ${body.eraResearch ?? false}`,
  1657. `pacingRule: "${body.pacingRule ?? ""}"`,
  1658. `satisfactionTypes: ${JSON.stringify(body.satisfactionTypes ?? [])}`,
  1659. `auditDimensions: ${JSON.stringify(body.auditDimensions ?? [])}`,
  1660. "---",
  1661. "",
  1662. body.body ?? "",
  1663. ].join("\n");
  1664. await writeFileFs(join(genresDir, `${body.id}.md`), frontmatter, "utf-8");
  1665. return c.json({ ok: true, id: body.id });
  1666. });
  1667. // --- Genre Edit ---
  1668. app.put("/api/v1/genres/:id", async (c) => {
  1669. const genreId = c.req.param("id");
  1670. if (/[/\\\0]/.test(genreId) || genreId.includes("..")) {
  1671. throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${genreId}"`);
  1672. }
  1673. const body = await c.req.json<{ profile: Record<string, unknown>; body: string }>();
  1674. const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
  1675. const genresDir = join(root, "genres");
  1676. await mkdirFs(genresDir, { recursive: true });
  1677. const p = body.profile;
  1678. const frontmatter = [
  1679. "---",
  1680. `name: ${p.name ?? genreId}`,
  1681. `id: ${p.id ?? genreId}`,
  1682. `language: ${p.language ?? "zh"}`,
  1683. `chapterTypes: ${JSON.stringify(p.chapterTypes ?? [])}`,
  1684. `fatigueWords: ${JSON.stringify(p.fatigueWords ?? [])}`,
  1685. `numericalSystem: ${p.numericalSystem ?? false}`,
  1686. `powerScaling: ${p.powerScaling ?? false}`,
  1687. `eraResearch: ${p.eraResearch ?? false}`,
  1688. `pacingRule: "${p.pacingRule ?? ""}"`,
  1689. `satisfactionTypes: ${JSON.stringify(p.satisfactionTypes ?? [])}`,
  1690. `auditDimensions: ${JSON.stringify(p.auditDimensions ?? [])}`,
  1691. "---",
  1692. "",
  1693. body.body ?? "",
  1694. ].join("\n");
  1695. await writeFileFs(join(genresDir, `${genreId}.md`), frontmatter, "utf-8");
  1696. return c.json({ ok: true, id: genreId });
  1697. });
  1698. // --- Genre Delete (project-level only) ---
  1699. app.delete("/api/v1/genres/:id", async (c) => {
  1700. const genreId = c.req.param("id");
  1701. if (/[/\\\0]/.test(genreId) || genreId.includes("..")) {
  1702. throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${genreId}"`);
  1703. }
  1704. const filePath = join(root, "genres", `${genreId}.md`);
  1705. try {
  1706. const { rm } = await import("node:fs/promises");
  1707. await rm(filePath);
  1708. return c.json({ ok: true, id: genreId });
  1709. } catch (e) {
  1710. return c.json({ error: `Genre "${genreId}" not found in project` }, 404);
  1711. }
  1712. });
  1713. // --- Style Analyze ---
  1714. app.post("/api/v1/style/analyze", async (c) => {
  1715. const { text, sourceName } = await c.req.json<{ text: string; sourceName: string }>();
  1716. if (!text?.trim()) return c.json({ error: "text is required" }, 400);
  1717. try {
  1718. const { analyzeStyle } = await import("@actalk/inkos-core");
  1719. const profile = analyzeStyle(text, sourceName ?? "unknown");
  1720. return c.json(profile);
  1721. } catch (e) {
  1722. return c.json({ error: String(e) }, 500);
  1723. }
  1724. });
  1725. // --- Style Import to Book ---
  1726. app.post("/api/v1/books/:id/style/import", async (c) => {
  1727. const id = c.req.param("id");
  1728. const { text, sourceName } = await c.req.json<{ text: string; sourceName: string }>();
  1729. broadcast("style:start", { bookId: id });
  1730. try {
  1731. const pipeline = new PipelineRunner(await buildPipelineConfig());
  1732. const result = await pipeline.generateStyleGuide(id, text, sourceName ?? "unknown");
  1733. broadcast("style:complete", { bookId: id });
  1734. return c.json({ ok: true, result });
  1735. } catch (e) {
  1736. broadcast("style:error", { bookId: id, error: String(e) });
  1737. return c.json({ error: String(e) }, 500);
  1738. }
  1739. });
  1740. // --- Import Chapters ---
  1741. app.post("/api/v1/books/:id/import/chapters", async (c) => {
  1742. const id = c.req.param("id");
  1743. const { text, splitRegex } = await c.req.json<{ text: string; splitRegex?: string }>();
  1744. if (!text?.trim()) return c.json({ error: "text is required" }, 400);
  1745. broadcast("import:start", { bookId: id, type: "chapters" });
  1746. try {
  1747. const { splitChapters } = await import("@actalk/inkos-core");
  1748. const chapters = [...splitChapters(text, splitRegex)];
  1749. const pipeline = new PipelineRunner(await buildPipelineConfig());
  1750. const result = await pipeline.importChapters({ bookId: id, chapters });
  1751. broadcast("import:complete", { bookId: id, type: "chapters", count: result.importedCount });
  1752. return c.json(result);
  1753. } catch (e) {
  1754. broadcast("import:error", { bookId: id, error: String(e) });
  1755. return c.json({ error: String(e) }, 500);
  1756. }
  1757. });
  1758. // --- Import Canon ---
  1759. app.post("/api/v1/books/:id/import/canon", async (c) => {
  1760. const id = c.req.param("id");
  1761. const { fromBookId } = await c.req.json<{ fromBookId: string }>();
  1762. if (!fromBookId) return c.json({ error: "fromBookId is required" }, 400);
  1763. broadcast("import:start", { bookId: id, type: "canon" });
  1764. try {
  1765. const pipeline = new PipelineRunner(await buildPipelineConfig());
  1766. await pipeline.importCanon(id, fromBookId);
  1767. broadcast("import:complete", { bookId: id, type: "canon" });
  1768. return c.json({ ok: true });
  1769. } catch (e) {
  1770. broadcast("import:error", { bookId: id, error: String(e) });
  1771. return c.json({ error: String(e) }, 500);
  1772. }
  1773. });
  1774. // --- Fanfic Init ---
  1775. app.post("/api/v1/fanfic/init", async (c) => {
  1776. const body = await c.req.json<{
  1777. title: string; sourceText: string; sourceName?: string;
  1778. mode?: string; genre?: string; platform?: string;
  1779. targetChapters?: number; chapterWordCount?: number; language?: string;
  1780. }>();
  1781. if (!body.title || !body.sourceText) {
  1782. return c.json({ error: "title and sourceText are required" }, 400);
  1783. }
  1784. const now = new Date().toISOString();
  1785. const bookId = body.title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, "-").replace(/-+/g, "-").slice(0, 30);
  1786. const bookConfig = {
  1787. id: bookId,
  1788. title: body.title,
  1789. platform: (body.platform ?? "other") as "other",
  1790. genre: (body.genre ?? "other") as "xuanhuan",
  1791. status: "outlining" as const,
  1792. targetChapters: body.targetChapters ?? 100,
  1793. chapterWordCount: body.chapterWordCount ?? 3000,
  1794. fanficMode: (body.mode ?? "canon") as "canon",
  1795. ...(body.language ? { language: body.language as "zh" | "en" } : {}),
  1796. createdAt: now,
  1797. updatedAt: now,
  1798. };
  1799. broadcast("fanfic:start", { bookId, title: body.title });
  1800. try {
  1801. const pipeline = new PipelineRunner(await buildPipelineConfig());
  1802. await pipeline.initFanficBook(bookConfig, body.sourceText, body.sourceName ?? "source", (body.mode ?? "canon") as "canon");
  1803. broadcast("fanfic:complete", { bookId });
  1804. return c.json({ ok: true, bookId });
  1805. } catch (e) {
  1806. broadcast("fanfic:error", { bookId, error: String(e) });
  1807. return c.json({ error: String(e) }, 500);
  1808. }
  1809. });
  1810. // --- Fanfic Show (read canon) ---
  1811. app.get("/api/v1/books/:id/fanfic", async (c) => {
  1812. const id = c.req.param("id");
  1813. const bookDir = state.bookDir(id);
  1814. try {
  1815. const content = await readFile(join(bookDir, "story", "fanfic_canon.md"), "utf-8");
  1816. return c.json({ bookId: id, content });
  1817. } catch {
  1818. return c.json({ bookId: id, content: null });
  1819. }
  1820. });
  1821. // --- Fanfic Refresh ---
  1822. app.post("/api/v1/books/:id/fanfic/refresh", async (c) => {
  1823. const id = c.req.param("id");
  1824. const { sourceText, sourceName } = await c.req.json<{ sourceText: string; sourceName?: string }>();
  1825. if (!sourceText?.trim()) return c.json({ error: "sourceText is required" }, 400);
  1826. broadcast("fanfic:refresh:start", { bookId: id });
  1827. try {
  1828. const book = await state.loadBookConfig(id);
  1829. const pipeline = new PipelineRunner(await buildPipelineConfig());
  1830. await pipeline.importFanficCanon(id, sourceText, sourceName ?? "source", (book.fanficMode ?? "canon") as "canon");
  1831. broadcast("fanfic:refresh:complete", { bookId: id });
  1832. return c.json({ ok: true });
  1833. } catch (e) {
  1834. broadcast("fanfic:refresh:error", { bookId: id, error: String(e) });
  1835. return c.json({ error: String(e) }, 500);
  1836. }
  1837. });
  1838. // --- Radar Scan ---
  1839. app.post("/api/v1/radar/scan", async (c) => {
  1840. broadcast("radar:start", {});
  1841. try {
  1842. const pipeline = new PipelineRunner(await buildPipelineConfig());
  1843. const result = await pipeline.runRadar();
  1844. broadcast("radar:complete", { result });
  1845. return c.json(result);
  1846. } catch (e) {
  1847. broadcast("radar:error", { error: String(e) });
  1848. return c.json({ error: String(e) }, 500);
  1849. }
  1850. });
  1851. // --- Doctor (environment health check) ---
  1852. app.get("/api/v1/doctor", async (c) => {
  1853. const { existsSync } = await import("node:fs");
  1854. const { GLOBAL_ENV_PATH } = await import("@actalk/inkos-core");
  1855. const checks = {
  1856. inkosJson: existsSync(join(root, "inkos.json")),
  1857. projectEnv: existsSync(join(root, ".env")),
  1858. globalEnv: existsSync(GLOBAL_ENV_PATH),
  1859. booksDir: existsSync(join(root, "books")),
  1860. llmConnected: false,
  1861. bookCount: 0,
  1862. };
  1863. try {
  1864. const books = await state.listBooks();
  1865. checks.bookCount = books.length;
  1866. } catch { /* ignore */ }
  1867. try {
  1868. const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
  1869. const client = createLLMClient(currentConfig.llm);
  1870. const { chatCompletion } = await import("@actalk/inkos-core");
  1871. await chatCompletion(client, currentConfig.llm.model, [{ role: "user", content: "ping" }], { maxTokens: 5 });
  1872. checks.llmConnected = true;
  1873. } catch { /* ignore */ }
  1874. return c.json(checks);
  1875. });
  1876. return app;
  1877. }
  1878. // --- Standalone runner ---
  1879. export async function startStudioServer(
  1880. root: string,
  1881. port = 4567,
  1882. options?: { readonly staticDir?: string },
  1883. ): Promise<void> {
  1884. const config = await loadProjectConfig(root, { requireApiKey: false });
  1885. const app = createStudioServer(config, root);
  1886. // Serve frontend static files — single process for API + frontend
  1887. if (options?.staticDir) {
  1888. const { readFile: readFileFs } = await import("node:fs/promises");
  1889. const { join: joinPath } = await import("node:path");
  1890. const { existsSync } = await import("node:fs");
  1891. // Serve static assets (js, css, etc.)
  1892. app.get("/assets/*", async (c) => {
  1893. const filePath = joinPath(options.staticDir!, c.req.path);
  1894. try {
  1895. const content = await readFileFs(filePath);
  1896. const ext = filePath.split(".").pop() ?? "";
  1897. const contentTypes: Record<string, string> = {
  1898. js: "application/javascript",
  1899. css: "text/css",
  1900. svg: "image/svg+xml",
  1901. png: "image/png",
  1902. ico: "image/x-icon",
  1903. json: "application/json",
  1904. };
  1905. return new Response(content, {
  1906. headers: { "Content-Type": contentTypes[ext] ?? "application/octet-stream" },
  1907. });
  1908. } catch {
  1909. return c.notFound();
  1910. }
  1911. });
  1912. // SPA fallback — serve index.html for all non-API routes
  1913. const indexPath = joinPath(options.staticDir!, "index.html");
  1914. if (existsSync(indexPath)) {
  1915. const indexHtml = await readFileFs(indexPath, "utf-8");
  1916. app.get("*", (c) => {
  1917. if (c.req.path.startsWith("/api/v1/")) return c.notFound();
  1918. return c.html(indexHtml);
  1919. });
  1920. }
  1921. }
  1922. console.log(`InkOS Studio running on http://localhost:${port}`);
  1923. serve({ fetch: app.fetch, port });
  1924. }