server.ts 82 KB

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