server.ts 101 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818
  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. listBookSessions,
  17. loadBookSession,
  18. appendManualSessionMessages,
  19. createAndPersistBookSession,
  20. renameBookSession,
  21. deleteBookSession,
  22. migrateBookSession,
  23. SessionAlreadyMigratedError,
  24. runAgentSession,
  25. buildAgentSystemPrompt,
  26. resolveServicePreset,
  27. resolveServiceProviderFamily,
  28. resolveServiceModelsBaseUrl,
  29. resolveServiceModel,
  30. loadSecrets,
  31. saveSecrets,
  32. listModelsForService,
  33. getAllEndpoints,
  34. probeModelsFromUpstream,
  35. chatCompletion,
  36. buildExportArtifact,
  37. GLOBAL_ENV_PATH,
  38. type ResolvedModel,
  39. type PipelineConfig,
  40. type ProjectConfig,
  41. type LogSink,
  42. type LogEntry,
  43. } from "@actalk/inkos-core";
  44. import { access, readFile, readdir, writeFile } from "node:fs/promises";
  45. import { join } from "node:path";
  46. import { isSafeBookId } from "./safety.js";
  47. import { ApiError } from "./errors.js";
  48. import { buildStudioBookConfig } from "./book-create.js";
  49. // -- Pipeline stage definitions per agent type --
  50. const PIPELINE_STAGES: Record<string, string[]> = {
  51. writer: [
  52. "准备章节输入", "撰写章节草稿", "落盘最终章节",
  53. "生成最终真相文件", "校验真相文件变更", "同步记忆索引",
  54. "更新章节索引与快照",
  55. ],
  56. architect: [
  57. "生成基础设定", "保存书籍配置", "写入基础设定文件",
  58. "初始化控制文档", "创建初始快照",
  59. ],
  60. reviser: [
  61. "加载修订上下文", "修订章节", "落盘修订结果",
  62. "更新索引与快照",
  63. ],
  64. auditor: ["审计章节"],
  65. };
  66. const AGENT_LABELS: Record<string, string> = {
  67. architect: "建书", writer: "写作", auditor: "审计",
  68. reviser: "修订", exporter: "导出",
  69. };
  70. const TOOL_LABELS: Record<string, string> = {
  71. read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
  72. };
  73. function resolveToolLabel(tool: string, agent?: string): string {
  74. if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
  75. return TOOL_LABELS[tool] ?? tool;
  76. }
  77. function summarizeResult(result: unknown): string {
  78. if (typeof result === "string") return result.slice(0, 200);
  79. if (result && typeof result === "object") {
  80. const r = result as Record<string, unknown>;
  81. if (typeof r.content === "string") return r.content.slice(0, 200);
  82. if (typeof r.text === "string") return r.text.slice(0, 200);
  83. }
  84. return String(result).slice(0, 200);
  85. }
  86. const NON_TEXT_MODEL_ID_PARTS = [
  87. "image",
  88. "embedding",
  89. "embed",
  90. "rerank",
  91. "tts",
  92. "speech",
  93. "audio",
  94. "moderation",
  95. ] as const;
  96. function isTextChatModelId(modelId: string): boolean {
  97. const normalized = modelId.trim().toLowerCase();
  98. if (!normalized) return false;
  99. return !NON_TEXT_MODEL_ID_PARTS.some((part) => normalized.includes(part));
  100. }
  101. function filterTextChatModels<T extends { readonly id: string }>(models: ReadonlyArray<T>): T[] {
  102. return models.filter((model) => isTextChatModelId(model.id));
  103. }
  104. function nonTextModelMessage(modelId: string): string {
  105. return `模型 ${modelId} 不适合文本聊天/写作。请在模型选择器中改用文本模型,例如 gemini-2.5-flash、gemini-2.5-pro 或对应服务的 chat 模型。`;
  106. }
  107. function extractToolError(result: unknown): string {
  108. if (typeof result === "string") return result.slice(0, 500);
  109. if (result && typeof result === "object") {
  110. const r = result as Record<string, unknown>;
  111. if (typeof r.content === "string") return r.content.slice(0, 500);
  112. if (r.content && Array.isArray(r.content)) {
  113. const textPart = r.content.find((c: any) => c.type === "text");
  114. if (textPart) return (textPart as any).text?.slice(0, 500) ?? "";
  115. }
  116. }
  117. return String(result).slice(0, 500);
  118. }
  119. interface CollectedToolExec {
  120. id: string;
  121. tool: string;
  122. agent?: string;
  123. label: string;
  124. status: "running" | "completed" | "error";
  125. args?: Record<string, unknown>;
  126. result?: string;
  127. details?: unknown;
  128. error?: string;
  129. stages?: Array<{ label: string; status: "pending" | "completed" }>;
  130. startedAt: number;
  131. completedAt?: number;
  132. }
  133. interface StudioBookListSummary {
  134. readonly id: string;
  135. readonly title: string;
  136. readonly genre: string;
  137. readonly status: string;
  138. readonly chaptersWritten: number;
  139. readonly [key: string]: unknown;
  140. }
  141. // --- Event bus for SSE ---
  142. type EventHandler = (event: string, data: unknown) => void;
  143. const subscribers = new Set<EventHandler>();
  144. const bookCreateStatus = new Map<string, { status: "creating" | "error"; error?: string }>();
  145. // 内存缓存:service -> 模型列表 + 更新时间戳;避免每次 sidebar 挂载时都打真实 LLM /models
  146. const modelListCache = new Map<string, { models: Array<{ id: string; name: string }>; at: number }>();
  147. interface ServiceConfigEntry {
  148. service: string;
  149. name?: string;
  150. baseUrl?: string;
  151. temperature?: number;
  152. apiFormat?: "chat" | "responses";
  153. stream?: boolean;
  154. }
  155. type LLMConfigSource = "env" | "studio";
  156. interface EnvConfigSummary {
  157. detected: boolean;
  158. provider: string | null;
  159. baseUrl: string | null;
  160. model: string | null;
  161. hasApiKey: boolean;
  162. }
  163. interface EnvConfigStatus {
  164. project: EnvConfigSummary;
  165. global: EnvConfigSummary;
  166. effectiveSource: "project" | "global" | null;
  167. runtimeUsesEnv: false;
  168. }
  169. interface ServiceProbeResult {
  170. ok: boolean;
  171. models: Array<{ id: string; name: string }>;
  172. selectedModel?: string;
  173. apiFormat?: "chat" | "responses";
  174. stream?: boolean;
  175. baseUrl?: string;
  176. modelsSource?: "api" | "fallback";
  177. error?: string;
  178. }
  179. function broadcast(event: string, data: unknown): void {
  180. for (const handler of subscribers) {
  181. handler(event, data);
  182. }
  183. }
  184. function deriveBookIdFromTitle(title: string): string {
  185. return title
  186. .trim()
  187. .toLowerCase()
  188. .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
  189. .replace(/-+/g, "-")
  190. .replace(/^-+|-+$/g, "")
  191. .slice(0, 30);
  192. }
  193. function resolveArchitectBookIdFromArgs(args?: Record<string, unknown>): string | null {
  194. if (!args || args.agent !== "architect" || args.revise === true) return null;
  195. if (typeof args.bookId === "string" && args.bookId.trim()) return args.bookId.trim();
  196. if (typeof args.title === "string" && args.title.trim()) {
  197. return deriveBookIdFromTitle(args.title) || null;
  198. }
  199. return null;
  200. }
  201. function resolveCreatedBookIdFromToolExecs(execs: ReadonlyArray<CollectedToolExec>): string | null {
  202. for (let i = execs.length - 1; i >= 0; i -= 1) {
  203. const exec = execs[i];
  204. if (exec.tool !== "sub_agent" || exec.agent !== "architect" || exec.status !== "completed") continue;
  205. const details = exec.details as { kind?: unknown; bookId?: unknown } | undefined;
  206. if (details?.kind === "book_created" && typeof details.bookId === "string" && details.bookId.trim()) {
  207. return details.bookId.trim();
  208. }
  209. const fromArgs = resolveArchitectBookIdFromArgs(exec.args);
  210. if (fromArgs) return fromArgs;
  211. }
  212. return null;
  213. }
  214. async function loadStudioBookListSummary(
  215. state: StateManager,
  216. bookId: string,
  217. ): Promise<StudioBookListSummary> {
  218. const book = await state.loadBookConfig(bookId);
  219. const nextChapter = await state.getNextChapterNumber(bookId);
  220. return { ...book, chaptersWritten: nextChapter - 1 };
  221. }
  222. function isCustomServiceId(serviceId: string): boolean {
  223. return serviceId === "custom" || serviceId.startsWith("custom:");
  224. }
  225. function serviceConfigKey(entry: ServiceConfigEntry): string {
  226. return entry.service === "custom" ? `custom:${entry.name ?? "Custom"}` : entry.service;
  227. }
  228. function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>): ServiceConfigEntry {
  229. if (serviceId.startsWith("custom:")) {
  230. return {
  231. service: "custom",
  232. name: decodeURIComponent(serviceId.slice("custom:".length)),
  233. ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
  234. ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
  235. ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
  236. ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
  237. };
  238. }
  239. if (serviceId === "custom") {
  240. return {
  241. service: "custom",
  242. ...(typeof value.name === "string" && value.name.length > 0 ? { name: value.name } : {}),
  243. ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
  244. ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
  245. ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
  246. ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
  247. };
  248. }
  249. return {
  250. service: serviceId,
  251. ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
  252. ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
  253. ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
  254. };
  255. }
  256. function normalizeConfigSource(value: unknown): LLMConfigSource {
  257. return value === "studio" ? "studio" : "env";
  258. }
  259. function normalizeServiceConfig(raw: unknown): ServiceConfigEntry[] {
  260. if (Array.isArray(raw)) {
  261. return raw
  262. .filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
  263. .map((entry) => ({
  264. service: typeof entry.service === "string" && entry.service.length > 0 ? entry.service : "custom",
  265. ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
  266. ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
  267. ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
  268. ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
  269. ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
  270. }));
  271. }
  272. if (raw && typeof raw === "object") {
  273. return Object.entries(raw as Record<string, unknown>)
  274. .filter(([, value]) => value && typeof value === "object")
  275. .map(([serviceId, value]) => normalizeServiceEntry(serviceId, value as Record<string, unknown>));
  276. }
  277. return [];
  278. }
  279. function mergeServiceConfig(existing: ServiceConfigEntry[], updates: ServiceConfigEntry[]): ServiceConfigEntry[] {
  280. const merged = new Map(existing.map((entry) => [serviceConfigKey(entry), entry]));
  281. for (const update of updates) {
  282. merged.set(serviceConfigKey(update), update);
  283. }
  284. return [...merged.values()];
  285. }
  286. async function loadRawConfig(root: string): Promise<Record<string, unknown>> {
  287. const configPath = join(root, "inkos.json");
  288. const raw = await readFile(configPath, "utf-8");
  289. return JSON.parse(raw) as Record<string, unknown>;
  290. }
  291. async function saveRawConfig(root: string, config: Record<string, unknown>): Promise<void> {
  292. await writeFile(join(root, "inkos.json"), JSON.stringify(config, null, 2), "utf-8");
  293. }
  294. async function readEnvConfigSummary(path: string): Promise<EnvConfigSummary> {
  295. try {
  296. const raw = await readFile(path, "utf-8");
  297. const values = new Map<string, string>();
  298. for (const line of raw.split(/\r?\n/)) {
  299. const trimmed = line.trim();
  300. if (!trimmed || trimmed.startsWith("#")) continue;
  301. const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
  302. if (!match) continue;
  303. const [, key, value] = match;
  304. values.set(key, value.trim());
  305. }
  306. const provider = values.get("INKOS_LLM_PROVIDER") ?? null;
  307. const baseUrl = values.get("INKOS_LLM_BASE_URL") ?? null;
  308. const model = values.get("INKOS_LLM_MODEL") ?? null;
  309. const apiKey = values.get("INKOS_LLM_API_KEY") ?? "";
  310. const detected = Boolean(provider || baseUrl || model || apiKey);
  311. return {
  312. detected,
  313. provider,
  314. baseUrl,
  315. model,
  316. hasApiKey: apiKey.length > 0,
  317. };
  318. } catch {
  319. return {
  320. detected: false,
  321. provider: null,
  322. baseUrl: null,
  323. model: null,
  324. hasApiKey: false,
  325. };
  326. }
  327. }
  328. async function readEnvConfigStatus(root: string): Promise<EnvConfigStatus> {
  329. const project = await readEnvConfigSummary(join(root, ".env"));
  330. const global = await readEnvConfigSummary(GLOBAL_ENV_PATH);
  331. return {
  332. project,
  333. global,
  334. effectiveSource: project.detected ? "project" : global.detected ? "global" : null,
  335. runtimeUsesEnv: false,
  336. };
  337. }
  338. async function resolveConfiguredServiceBaseUrl(root: string, serviceId: string, inlineBaseUrl?: string): Promise<string | undefined> {
  339. if (inlineBaseUrl?.trim()) return inlineBaseUrl.trim();
  340. if (!isCustomServiceId(serviceId)) {
  341. return resolveServicePreset(serviceId)?.baseUrl;
  342. }
  343. try {
  344. const config = await loadRawConfig(root);
  345. const services = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services);
  346. const matched = services.find((entry) => serviceConfigKey(entry) === serviceId);
  347. return matched?.baseUrl;
  348. } catch {
  349. return undefined;
  350. }
  351. }
  352. async function resolveConfiguredServiceEntry(root: string, serviceId: string): Promise<ServiceConfigEntry | undefined> {
  353. try {
  354. const config = await loadRawConfig(root);
  355. const services = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services);
  356. return services.find((entry) => serviceConfigKey(entry) === serviceId);
  357. } catch {
  358. return undefined;
  359. }
  360. }
  361. function buildProbePlans(
  362. preferredApiFormat: "chat" | "responses" | undefined,
  363. preferredStream: boolean | undefined,
  364. ): Array<{ apiFormat: "chat" | "responses"; stream: boolean }> {
  365. const candidates: Array<{ apiFormat: "chat" | "responses"; stream: boolean }> = [];
  366. const seen = new Set<string>();
  367. const push = (apiFormat: "chat" | "responses", stream: boolean) => {
  368. const key = `${apiFormat}:${stream ? "1" : "0"}`;
  369. if (seen.has(key)) return;
  370. seen.add(key);
  371. candidates.push({ apiFormat, stream });
  372. };
  373. if (preferredApiFormat) {
  374. push(preferredApiFormat, preferredStream ?? false);
  375. push(preferredApiFormat, !(preferredStream ?? false));
  376. }
  377. const alternate = preferredApiFormat === "responses" ? "chat" : "responses";
  378. push(alternate, false);
  379. push(alternate, true);
  380. push("chat", false);
  381. push("chat", true);
  382. push("responses", false);
  383. push("responses", true);
  384. return candidates;
  385. }
  386. function buildModelCandidates(args: {
  387. preferredModel?: string;
  388. configModel?: string;
  389. envModel?: string | null;
  390. discoveredModels: Array<{ id: string; name: string }>;
  391. includeGenericFallbacks?: boolean;
  392. }): string[] {
  393. const seen = new Set<string>();
  394. const candidates: string[] = [];
  395. const push = (value: string | null | undefined) => {
  396. if (!value || value.trim().length === 0) return;
  397. const id = value.trim();
  398. if (seen.has(id)) return;
  399. seen.add(id);
  400. candidates.push(id);
  401. };
  402. push(args.preferredModel);
  403. push(args.configModel);
  404. push(args.envModel ?? undefined);
  405. for (const model of args.discoveredModels) push(model.id);
  406. if (args.includeGenericFallbacks === false) return candidates;
  407. push("gpt-5.4");
  408. push("gpt-4o");
  409. push("claude-sonnet-4-6");
  410. push("MiniMax-M2.7");
  411. push("kimi-k2.5");
  412. return candidates;
  413. }
  414. function formatServiceProbeError(args: {
  415. readonly service: string;
  416. readonly label?: string;
  417. readonly baseUrl: string;
  418. readonly model?: string;
  419. readonly apiFormat?: "chat" | "responses";
  420. readonly stream?: boolean;
  421. readonly error: string;
  422. }): string {
  423. const rawDetail = args.error
  424. .replace(/\n\s*\(baseUrl:[\s\S]*?\)$/m, "")
  425. .trim();
  426. const upstreamDetail = rawDetail.includes("上游详情:")
  427. ? rawDetail
  428. : "";
  429. const context = [
  430. `服务商:${args.label ?? args.service}`,
  431. `测试模型:${args.model ?? "未确定"}`,
  432. `协议:${args.apiFormat === "responses" ? "Responses" : "Chat / Completions"}${typeof args.stream === "boolean" ? `,${args.stream ? "流式" : "非流式"}` : ""}`,
  433. `Base URL:${args.baseUrl}`,
  434. ].join("\n");
  435. if (args.service === "google") {
  436. return [
  437. "Google Gemini 测试连接失败。",
  438. context,
  439. "",
  440. "请优先检查:",
  441. "1. API Key 是否来自 Google AI Studio 的 Gemini API key,而不是 OAuth、Vertex AI 或其它 Google 服务凭据。",
  442. "2. 该 key 所属项目是否已启用 Gemini API,并且没有被限制到其它 API、来源或服务。",
  443. "3. 当前地区/账号是否允许访问 Gemini API。",
  444. "4. 如果 key 曾经泄露,请在 AI Studio 重新生成后再保存。",
  445. upstreamDetail ? `\n上游返回:${upstreamDetail}` : "",
  446. ].filter(Boolean).join("\n");
  447. }
  448. if (args.service === "moonshot" || args.service === "kimiCodingPlan") {
  449. return [
  450. `${args.label ?? args.service} 测试连接失败。`,
  451. context,
  452. "",
  453. "请优先检查模型是否可用,以及 kimi-k2.x 这类模型是否需要 temperature=1。",
  454. rawDetail ? `\n上游返回:${rawDetail}` : "",
  455. ].filter(Boolean).join("\n");
  456. }
  457. return [
  458. `${args.label ?? args.service} 测试连接失败。`,
  459. context,
  460. "",
  461. "请检查 API Key、模型可用性、账号额度,以及协议类型是否匹配该服务商。",
  462. rawDetail ? `\n上游返回:${rawDetail}` : "",
  463. ].filter(Boolean).join("\n");
  464. }
  465. async function fetchModelsFromServiceBaseUrl(
  466. serviceId: string,
  467. baseUrl: string,
  468. apiKey: string,
  469. ): Promise<{ models: Array<{ id: string; name: string }>; error?: string; authFailed?: boolean }> {
  470. const endpoint = isCustomServiceId(serviceId)
  471. ? undefined
  472. : getAllEndpoints().find((ep) => ep.id === serviceId);
  473. const modelsBaseUrl = isCustomServiceId(serviceId)
  474. ? baseUrl
  475. : endpoint?.modelsBaseUrl ?? (endpoint ? baseUrl : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl);
  476. const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
  477. try {
  478. const res = await fetch(modelsUrl, {
  479. headers: { Authorization: `Bearer ${apiKey}` },
  480. signal: AbortSignal.timeout(10_000),
  481. });
  482. if (!res.ok) {
  483. const body = await res.text().catch(() => "");
  484. return {
  485. models: [],
  486. error: `服务商返回 ${res.status}: ${body.slice(0, 200)}`,
  487. authFailed: res.status === 401 || res.status === 403,
  488. };
  489. }
  490. const json = await res.json() as { data?: Array<{ id: string }> };
  491. return {
  492. models: (json.data ?? []).map((m) => ({ id: m.id, name: m.id })),
  493. };
  494. } catch (error) {
  495. return {
  496. models: [],
  497. error: error instanceof Error ? error.message : String(error),
  498. };
  499. }
  500. }
  501. async function probeServiceCapabilities(args: {
  502. root: string;
  503. service: string;
  504. apiKey: string;
  505. baseUrl: string;
  506. preferredApiFormat?: "chat" | "responses";
  507. preferredStream?: boolean;
  508. preferredModel?: string;
  509. }): Promise<ServiceProbeResult> {
  510. const rawConfig = await loadRawConfig(args.root).catch(() => ({} as Record<string, unknown>));
  511. const llm = (rawConfig.llm as Record<string, unknown> | undefined) ?? {};
  512. const envConfig = await readEnvConfigStatus(args.root);
  513. const envModel = envConfig.effectiveSource === "project"
  514. ? envConfig.project.model
  515. : envConfig.effectiveSource === "global"
  516. ? envConfig.global.model
  517. : null;
  518. const baseService = isCustomServiceId(args.service) ? "custom" : args.service;
  519. const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey);
  520. if (modelsResponse.authFailed) {
  521. return {
  522. ok: false,
  523. models: [],
  524. error: modelsResponse.error ?? "API Key 无效或无权访问模型列表。",
  525. };
  526. }
  527. const discoveredModels = modelsResponse.models;
  528. // For bank services, probe with the service's own check model first — not the global default.
  529. const endpoint = getAllEndpoints().find((ep) => ep.id === baseService);
  530. const preset = resolveServicePreset(baseService);
  531. const serviceFirstModel =
  532. endpoint?.checkModel
  533. ?? preset?.knownModels?.[0]
  534. ?? endpoint?.models.find((model) => model.enabled !== false)?.id;
  535. const useEndpointCheckModel = !isCustomServiceId(args.service) && Boolean(endpoint?.checkModel);
  536. const configService = typeof llm.service === "string" ? llm.service : undefined;
  537. const configModel = !useEndpointCheckModel && configService === args.service
  538. ? typeof llm.defaultModel === "string"
  539. ? llm.defaultModel
  540. : typeof llm.model === "string"
  541. ? llm.model
  542. : undefined
  543. : undefined;
  544. const useCustomFallbacks = isCustomServiceId(args.service);
  545. const modelCandidates = buildModelCandidates({
  546. preferredModel: args.preferredModel ?? serviceFirstModel,
  547. configModel,
  548. envModel: useCustomFallbacks ? envModel : undefined,
  549. discoveredModels: useEndpointCheckModel ? [] : discoveredModels,
  550. includeGenericFallbacks: useCustomFallbacks,
  551. });
  552. if (modelCandidates.length === 0) {
  553. return {
  554. ok: false,
  555. models: [],
  556. error: "无法自动确定模型,请先填写可用模型或提供支持 /models 的服务端点。",
  557. };
  558. }
  559. let lastError = modelsResponse.error ?? "自动探测失败";
  560. for (const model of modelCandidates) {
  561. for (const plan of buildProbePlans(args.preferredApiFormat, args.preferredStream)) {
  562. const client = createLLMClient({
  563. provider: resolveServiceProviderFamily(baseService) ?? "openai",
  564. service: baseService,
  565. configSource: "studio",
  566. baseUrl: args.baseUrl,
  567. apiKey: args.apiKey.trim(),
  568. model,
  569. temperature: 0.7,
  570. maxTokens: 2048,
  571. thinkingBudget: 0,
  572. apiFormat: plan.apiFormat,
  573. stream: plan.stream,
  574. } as ProjectConfig["llm"]);
  575. try {
  576. await chatCompletion(client, model, [{ role: "user", content: "ping" }], { maxTokens: 2048 });
  577. const models = discoveredModels.length > 0
  578. ? discoveredModels
  579. : endpoint?.models
  580. .filter((m) => m.enabled !== false)
  581. .filter((m) => isTextChatModelId(m.id))
  582. .map((m) => ({ id: m.id, name: m.id }))
  583. ?? preset?.knownModels?.map((id) => ({ id, name: id }))
  584. ?? [{ id: model, name: model }];
  585. return {
  586. ok: true,
  587. models,
  588. selectedModel: model,
  589. apiFormat: plan.apiFormat,
  590. stream: plan.stream,
  591. baseUrl: args.baseUrl,
  592. modelsSource: discoveredModels.length > 0 ? "api" : "fallback",
  593. };
  594. } catch (error) {
  595. lastError = formatServiceProbeError({
  596. service: baseService,
  597. label: endpoint?.label ?? preset?.label,
  598. baseUrl: args.baseUrl,
  599. model,
  600. apiFormat: plan.apiFormat,
  601. stream: plan.stream,
  602. error: error instanceof Error ? error.message : String(error),
  603. });
  604. }
  605. }
  606. }
  607. return {
  608. ok: false,
  609. models: discoveredModels,
  610. error: lastError,
  611. };
  612. }
  613. // --- Server factory ---
  614. export function createStudioServer(initialConfig: ProjectConfig, root: string) {
  615. const app = new Hono();
  616. const state = new StateManager(root);
  617. let cachedConfig = initialConfig;
  618. app.use("/*", cors());
  619. // Structured error handler — ApiError returns typed JSON, others return 500
  620. app.onError((error, c) => {
  621. if (error instanceof ApiError) {
  622. return c.json({ error: { code: error.code, message: error.message } }, error.status as 400);
  623. }
  624. return c.json(
  625. { error: { code: "INTERNAL_ERROR", message: "Unexpected server error." } },
  626. 500,
  627. );
  628. });
  629. // BookId validation middleware — blocks path traversal on all book routes
  630. app.use("/api/v1/books/:id/*", async (c, next) => {
  631. const bookId = c.req.param("id");
  632. if (!isSafeBookId(bookId)) {
  633. throw new ApiError(400, "INVALID_BOOK_ID", `Invalid book ID: "${bookId}"`);
  634. }
  635. await next();
  636. });
  637. app.use("/api/v1/books/:id", async (c, next) => {
  638. const bookId = c.req.param("id");
  639. if (!isSafeBookId(bookId)) {
  640. throw new ApiError(400, "INVALID_BOOK_ID", `Invalid book ID: "${bookId}"`);
  641. }
  642. await next();
  643. });
  644. // Logger sink that broadcasts to SSE
  645. const sseSink: LogSink = {
  646. write(entry: LogEntry): void {
  647. broadcast("log", { level: entry.level, tag: entry.tag, message: entry.message });
  648. },
  649. };
  650. // Logger sink that prints to server terminal
  651. const consoleSink: LogSink = {
  652. write(entry: LogEntry): void {
  653. const prefix = `[${entry.tag}]`;
  654. if (entry.level === "warn") console.warn(prefix, entry.message);
  655. else if (entry.level === "error") console.error(prefix, entry.message);
  656. else console.log(prefix, entry.message);
  657. },
  658. };
  659. async function loadCurrentProjectConfig(
  660. options?: { readonly requireApiKey?: boolean },
  661. ): Promise<ProjectConfig> {
  662. const freshConfig = await loadProjectConfig(root, { ...options, consumer: "studio" });
  663. cachedConfig = freshConfig;
  664. return freshConfig;
  665. }
  666. async function buildPipelineConfig(
  667. overrides?: Partial<Pick<PipelineConfig, "externalContext" | "client" | "model">> & {
  668. readonly currentConfig?: ProjectConfig;
  669. readonly sessionIdForSSE?: string;
  670. },
  671. ): Promise<PipelineConfig> {
  672. const currentConfig = overrides?.currentConfig ?? await loadCurrentProjectConfig();
  673. const scopedSseSink: LogSink = overrides?.sessionIdForSSE
  674. ? {
  675. write(entry) {
  676. broadcast("log", {
  677. sessionId: overrides.sessionIdForSSE,
  678. level: entry.level,
  679. tag: entry.tag,
  680. message: entry.message,
  681. });
  682. },
  683. }
  684. : sseSink;
  685. const logger = createLogger({ tag: "studio", sinks: [scopedSseSink, consoleSink] });
  686. return {
  687. client: overrides?.client ?? createLLMClient(currentConfig.llm),
  688. model: overrides?.model ?? currentConfig.llm.model,
  689. projectRoot: root,
  690. defaultLLMConfig: currentConfig.llm,
  691. modelOverrides: currentConfig.modelOverrides,
  692. notifyChannels: currentConfig.notify,
  693. logger,
  694. onStreamProgress: (progress) => {
  695. broadcast("llm:progress", {
  696. ...(overrides?.sessionIdForSSE ? { sessionId: overrides.sessionIdForSSE } : {}),
  697. status: progress.status,
  698. elapsedMs: progress.elapsedMs,
  699. totalChars: progress.totalChars,
  700. chineseChars: progress.chineseChars,
  701. });
  702. },
  703. externalContext: overrides?.externalContext,
  704. };
  705. }
  706. // --- Books ---
  707. app.get("/api/v1/books", async (c) => {
  708. const bookIds = await state.listBooks();
  709. const books = await Promise.all(bookIds.map((id) => loadStudioBookListSummary(state, id)));
  710. return c.json({ books });
  711. });
  712. app.get("/api/v1/books/:id", async (c) => {
  713. const id = c.req.param("id");
  714. try {
  715. const book = await state.loadBookConfig(id);
  716. const chapters = await state.loadChapterIndex(id);
  717. const nextChapter = await state.getNextChapterNumber(id);
  718. return c.json({ book, chapters, nextChapter });
  719. } catch {
  720. return c.json({ error: `Book "${id}" not found` }, 404);
  721. }
  722. });
  723. // --- Genres ---
  724. app.get("/api/v1/genres", async (c) => {
  725. const { listAvailableGenres, readGenreProfile } = await import("@actalk/inkos-core");
  726. const rawGenres = await listAvailableGenres(root);
  727. const genres = await Promise.all(
  728. rawGenres.map(async (g) => {
  729. try {
  730. const { profile } = await readGenreProfile(root, g.id);
  731. return { ...g, language: profile.language ?? "zh" };
  732. } catch {
  733. return { ...g, language: "zh" };
  734. }
  735. }),
  736. );
  737. return c.json({ genres });
  738. });
  739. // --- Book Create ---
  740. app.post("/api/v1/books/create", async (c) => {
  741. const body = await c.req.json<{
  742. title: string;
  743. genre: string;
  744. language?: string;
  745. platform?: string;
  746. chapterWordCount?: number;
  747. targetChapters?: number;
  748. }>();
  749. const now = new Date().toISOString();
  750. const bookConfig = buildStudioBookConfig(body, now);
  751. const bookId = bookConfig.id;
  752. const bookDir = state.bookDir(bookId);
  753. try {
  754. await access(join(bookDir, "book.json"));
  755. await access(join(bookDir, "story", "story_bible.md"));
  756. return c.json({ error: `Book "${bookId}" already exists` }, 409);
  757. } catch {
  758. // The target book is not fully initialized yet, so creation can continue.
  759. }
  760. broadcast("book:creating", { bookId, title: body.title });
  761. bookCreateStatus.set(bookId, { status: "creating" });
  762. const pipeline = new PipelineRunner(await buildPipelineConfig());
  763. const tools = createInteractionToolsFromDeps(pipeline, state);
  764. processProjectInteractionRequest({
  765. projectRoot: root,
  766. request: {
  767. intent: "create_book",
  768. title: body.title,
  769. genre: body.genre,
  770. language: body.language === "en" ? "en" : body.language === "zh" ? "zh" : undefined,
  771. platform: body.platform,
  772. chapterWordCount: body.chapterWordCount,
  773. targetChapters: body.targetChapters,
  774. },
  775. tools,
  776. }).then(
  777. async (result: {
  778. readonly session: { readonly activeBookId?: string };
  779. readonly details?: Readonly<Record<string, unknown>>;
  780. }) => {
  781. const createdBookId = (result.details?.bookId as string | undefined) ?? result.session.activeBookId ?? bookId;
  782. const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
  783. bookCreateStatus.delete(createdBookId);
  784. broadcast("book:created", { bookId: createdBookId, ...(book ? { book } : {}) });
  785. },
  786. (e: unknown) => {
  787. const error = e instanceof Error ? e.message : String(e);
  788. bookCreateStatus.set(bookId, { status: "error", error });
  789. broadcast("book:error", { bookId, error });
  790. },
  791. );
  792. return c.json({ status: "creating", bookId });
  793. });
  794. app.get("/api/v1/books/:id/create-status", async (c) => {
  795. const id = c.req.param("id");
  796. const status = bookCreateStatus.get(id);
  797. if (!status) {
  798. return c.json({ status: "missing" }, 404);
  799. }
  800. return c.json(status);
  801. });
  802. // --- Chapters ---
  803. app.get("/api/v1/books/:id/chapters/:num", async (c) => {
  804. const id = c.req.param("id");
  805. const num = parseInt(c.req.param("num"), 10);
  806. const bookDir = state.bookDir(id);
  807. const chaptersDir = join(bookDir, "chapters");
  808. try {
  809. const files = await readdir(chaptersDir);
  810. const paddedNum = String(num).padStart(4, "0");
  811. const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  812. if (!match) return c.json({ error: "Chapter not found" }, 404);
  813. const content = await readFile(join(chaptersDir, match), "utf-8");
  814. return c.json({ chapterNumber: num, filename: match, content });
  815. } catch {
  816. return c.json({ error: "Chapter not found" }, 404);
  817. }
  818. });
  819. // --- Chapter Save ---
  820. app.put("/api/v1/books/:id/chapters/:num", async (c) => {
  821. const id = c.req.param("id");
  822. const num = parseInt(c.req.param("num"), 10);
  823. const bookDir = state.bookDir(id);
  824. const chaptersDir = join(bookDir, "chapters");
  825. const { content } = await c.req.json<{ content: string }>();
  826. try {
  827. const files = await readdir(chaptersDir);
  828. const paddedNum = String(num).padStart(4, "0");
  829. const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  830. if (!match) return c.json({ error: "Chapter not found" }, 404);
  831. const { writeFile: writeFileFs } = await import("node:fs/promises");
  832. await writeFileFs(join(chaptersDir, match), content, "utf-8");
  833. return c.json({ ok: true, chapterNumber: num });
  834. } catch (e) {
  835. return c.json({ error: String(e) }, 500);
  836. }
  837. });
  838. // --- Truth files ---
  839. // Flat-file whitelist — the pre-Phase-5 story root files plus dev's legacy
  840. // editor targets (author_intent / current_focus / volume_outline).
  841. //
  842. // Phase 5 cleanup #3 moved the authoritative YAML frontmatter + outline prose
  843. // into story/outline/ and character sheets into story/roles/. `story_bible.md`
  844. // and `book_rules.md` now exist only as compat pointer shims — we still allow
  845. // reading them so legacy books keep rendering, but the server-side writer
  846. // (write_truth_file) no longer accepts them as edit targets.
  847. const TRUTH_FLAT_FILES = [
  848. "author_intent.md", "current_focus.md",
  849. "story_bible.md", "book_rules.md", "volume_outline.md", "current_state.md",
  850. "particle_ledger.md", "pending_hooks.md", "chapter_summaries.md",
  851. "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
  852. "style_guide.md", "parent_canon.md", "fanfic_canon.md",
  853. ];
  854. // Authoritative Phase 5 paths — prose outline + role sheets live under
  855. // dedicated subdirectories of story/. The full path (relative to story/) is
  856. // matched literally here. `节奏原则.md` / `rhythm_principles.md` is optional
  857. // after Phase 5 consolidation (rhythm lives in volume_map's closing paragraph);
  858. // the entries stay whitelisted for legacy books and manual overrides.
  859. const TRUTH_OUTLINE_FILES = [
  860. "outline/story_frame.md",
  861. "outline/volume_map.md",
  862. "outline/节奏原则.md",
  863. "outline/rhythm_principles.md",
  864. ];
  865. // Pointer shims that the runtime no longer treats as authoritative. The
  866. // GET handler tags them with `legacy: true` so the UI can surface that the
  867. // edits won't land where the user expects.
  868. const LEGACY_SHIM_FILES = new Set(["story_bible.md", "book_rules.md"]);
  869. /**
  870. * Validate a requested truth-file path:
  871. * 1. Must be one of the declared flat files, an outline/* allow-listed
  872. * entry, or a roles/**\/*.md file under 主要角色/ | 次要角色/.
  873. * 2. Must resolve to a path inside bookDir/story/ (no `..`, no absolute
  874. * paths, no traversal via the tier-name segment).
  875. */
  876. function resolveTruthFilePath(bookDir: string, file: string): string | null {
  877. // Reject absolute paths, traversal, null bytes outright.
  878. if (!file || file.includes("\0") || file.startsWith("/") || file.includes("..")) {
  879. return null;
  880. }
  881. // Phase hotfix 3: accept both Chinese and English locale role dirs so
  882. // English-layout books (roles/major, roles/minor) are reachable through
  883. // Studio. The runtime reader (utils/outline-paths.ts:75) already scans
  884. // both — Studio used to drop English books to read-only.
  885. const allowed =
  886. TRUTH_FLAT_FILES.includes(file)
  887. || TRUTH_OUTLINE_FILES.includes(file)
  888. || /^roles\/(主要角色|次要角色|major|minor)\/[^/]+\.md$/.test(file);
  889. if (!allowed) return null;
  890. const storyDir = join(bookDir, "story");
  891. const resolved = join(storyDir, file);
  892. // Path safety: resolved absolute path must sit inside storyDir. `join`
  893. // collapses any `..` segments, so compare against the storyDir prefix.
  894. const storyPrefix = storyDir.endsWith("/") ? storyDir : storyDir + "/";
  895. if (!resolved.startsWith(storyPrefix) && resolved !== storyDir) {
  896. return null;
  897. }
  898. return resolved;
  899. }
  900. async function fileExists(path: string): Promise<boolean> {
  901. try {
  902. await access(path);
  903. return true;
  904. } catch {
  905. return false;
  906. }
  907. }
  908. // Use `:file{.+}` wildcard so nested paths (outline/..., roles/.../...) match.
  909. app.get("/api/v1/books/:id/truth/:file{.+}", async (c) => {
  910. const file = c.req.param("file");
  911. const id = c.req.param("id");
  912. const bookDir = state.bookDir(id);
  913. const resolved = resolveTruthFilePath(bookDir, file);
  914. if (!resolved) {
  915. return c.json({ error: "Invalid truth file" }, 400);
  916. }
  917. // Phase 5: new-layout books keep the authoritative prose under outline/.
  918. // A legacy book may only have story_bible.md / book_rules.md on disk —
  919. // we still serve those for read-only display, but flag them so the UI
  920. // can warn users their edits won't reach the runtime.
  921. // Hotfix: only tag as legacy when the book actually HAS the new layout.
  922. // Pre-Phase-5 books use story_bible/book_rules as the authoritative source.
  923. const { isNewLayoutBook } = await import("@actalk/inkos-core");
  924. const legacy = LEGACY_SHIM_FILES.has(file) && await isNewLayoutBook(bookDir);
  925. try {
  926. const content = await readFile(resolved, "utf-8");
  927. return c.json({ file, content, ...(legacy ? { legacy: true } : {}) });
  928. } catch {
  929. return c.json({ file, content: null, ...(legacy ? { legacy: true } : {}) });
  930. }
  931. });
  932. // --- Analytics ---
  933. app.get("/api/v1/books/:id/analytics", async (c) => {
  934. const id = c.req.param("id");
  935. try {
  936. const chapters = await state.loadChapterIndex(id);
  937. return c.json(computeAnalytics(id, chapters));
  938. } catch {
  939. return c.json({ error: `Book "${id}" not found` }, 404);
  940. }
  941. });
  942. // --- Actions ---
  943. app.post("/api/v1/books/:id/write-next", async (c) => {
  944. const id = c.req.param("id");
  945. const body = await c.req.json<{ wordCount?: number }>().catch(() => ({ wordCount: undefined }));
  946. broadcast("write:start", { bookId: id });
  947. // Fire and forget — progress/completion/errors pushed via SSE
  948. const pipeline = new PipelineRunner(await buildPipelineConfig());
  949. pipeline.writeNextChapter(id, body.wordCount).then(
  950. (result) => {
  951. broadcast("write:complete", { bookId: id, chapterNumber: result.chapterNumber, status: result.status, title: result.title, wordCount: result.wordCount });
  952. },
  953. (e) => {
  954. broadcast("write:error", { bookId: id, error: e instanceof Error ? e.message : String(e) });
  955. },
  956. );
  957. return c.json({ status: "writing", bookId: id });
  958. });
  959. app.post("/api/v1/books/:id/draft", async (c) => {
  960. const id = c.req.param("id");
  961. const body = await c.req.json<{ wordCount?: number; context?: string }>().catch(() => ({ wordCount: undefined, context: undefined }));
  962. broadcast("draft:start", { bookId: id });
  963. const pipeline = new PipelineRunner(await buildPipelineConfig());
  964. pipeline.writeDraft(id, body.context, body.wordCount).then(
  965. (result) => {
  966. broadcast("draft:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount });
  967. },
  968. (e) => {
  969. broadcast("draft:error", { bookId: id, error: e instanceof Error ? e.message : String(e) });
  970. },
  971. );
  972. return c.json({ status: "drafting", bookId: id });
  973. });
  974. app.post("/api/v1/books/:id/chapters/:num/approve", async (c) => {
  975. const id = c.req.param("id");
  976. const num = parseInt(c.req.param("num"), 10);
  977. try {
  978. const index = await state.loadChapterIndex(id);
  979. const updated = index.map((ch) =>
  980. ch.number === num ? { ...ch, status: "approved" as const } : ch,
  981. );
  982. await state.saveChapterIndex(id, updated);
  983. return c.json({ ok: true, chapterNumber: num, status: "approved" });
  984. } catch (e) {
  985. return c.json({ error: String(e) }, 500);
  986. }
  987. });
  988. app.post("/api/v1/books/:id/chapters/:num/reject", async (c) => {
  989. const id = c.req.param("id");
  990. const num = parseInt(c.req.param("num"), 10);
  991. try {
  992. const index = await state.loadChapterIndex(id);
  993. const target = index.find((ch) => ch.number === num);
  994. if (!target) {
  995. return c.json({ error: `Chapter ${num} not found` }, 404);
  996. }
  997. const rollbackTarget = num - 1;
  998. const discarded = await state.rollbackToChapter(id, rollbackTarget);
  999. return c.json({
  1000. ok: true,
  1001. chapterNumber: num,
  1002. status: "rejected",
  1003. rolledBackTo: rollbackTarget,
  1004. discarded,
  1005. });
  1006. } catch (e) {
  1007. return c.json({ error: String(e) }, 500);
  1008. }
  1009. });
  1010. // --- SSE ---
  1011. app.get("/api/v1/events", (c) => {
  1012. return streamSSE(c, async (stream) => {
  1013. const handler: EventHandler = (event, data) => {
  1014. stream.writeSSE({ event, data: JSON.stringify(data) });
  1015. };
  1016. subscribers.add(handler);
  1017. await stream.writeSSE({ event: "ping", data: "" });
  1018. // Keep alive
  1019. const keepAlive = setInterval(() => {
  1020. stream.writeSSE({ event: "ping", data: "" });
  1021. }, 30000);
  1022. stream.onAbort(() => {
  1023. subscribers.delete(handler);
  1024. clearInterval(keepAlive);
  1025. });
  1026. // Block until aborted
  1027. await new Promise(() => {});
  1028. });
  1029. });
  1030. // --- Model discovery ---
  1031. app.get("/api/v1/services", async (c) => {
  1032. const secrets = await loadSecrets(root);
  1033. const endpoints = getAllEndpoints().filter((ep) => ep.id !== "custom");
  1034. // Fast: only check connection status from secrets, no external API calls.
  1035. const services = endpoints.map((ep) => ({
  1036. service: ep.id,
  1037. label: ep.label,
  1038. group: ep.group,
  1039. connected: Boolean(secrets.services[ep.id]?.apiKey),
  1040. }));
  1041. // Add custom services from inkos.json
  1042. try {
  1043. const config = await loadRawConfig(root);
  1044. for (const svc of normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services)) {
  1045. if (svc.service === "custom") {
  1046. const secretKey = `custom:${svc.name}`;
  1047. services.push({
  1048. service: secretKey,
  1049. label: svc.name ?? "Custom",
  1050. group: undefined,
  1051. connected: Boolean(secrets.services[secretKey]?.apiKey),
  1052. });
  1053. }
  1054. }
  1055. } catch { /* no config file */ }
  1056. return c.json({ services });
  1057. });
  1058. app.get("/api/v1/services/config", async (c) => {
  1059. const config = await loadRawConfig(root);
  1060. const llm = (config.llm as Record<string, unknown> | undefined) ?? {};
  1061. const services = normalizeServiceConfig(llm.services);
  1062. const envConfig = await readEnvConfigStatus(root);
  1063. return c.json({
  1064. services,
  1065. defaultModel: llm.defaultModel ?? null,
  1066. configSource: "studio" satisfies LLMConfigSource,
  1067. storedConfigSource: normalizeConfigSource(llm.configSource),
  1068. envConfig,
  1069. });
  1070. });
  1071. app.put("/api/v1/services/config", async (c) => {
  1072. const body = await c.req.json<{ services?: unknown; defaultModel?: string; configSource?: LLMConfigSource; service?: string }>();
  1073. const config = await loadRawConfig(root);
  1074. config.llm = config.llm ?? {};
  1075. const llm = config.llm as Record<string, unknown>;
  1076. if (body.services !== undefined) {
  1077. const existingServices = normalizeServiceConfig(llm.services);
  1078. const incomingServices = normalizeServiceConfig(body.services);
  1079. llm.services = mergeServiceConfig(existingServices, incomingServices);
  1080. }
  1081. if (body.defaultModel !== undefined) {
  1082. llm.defaultModel = body.defaultModel;
  1083. }
  1084. if (body.configSource === "env") {
  1085. return c.json({
  1086. error: "Studio 运行时不支持切换到 env;env 只在 CLI/daemon/部署运行时作为覆盖层使用。",
  1087. }, 400);
  1088. }
  1089. if (body.configSource !== undefined) {
  1090. llm.configSource = normalizeConfigSource(body.configSource);
  1091. }
  1092. if (body.service !== undefined) {
  1093. llm.service = body.service;
  1094. }
  1095. await saveRawConfig(root, config);
  1096. return c.json({ ok: true });
  1097. });
  1098. app.post("/api/v1/services/:service/test", async (c) => {
  1099. const service = c.req.param("service");
  1100. const { apiKey, baseUrl, apiFormat, stream } = await c.req.json<{
  1101. apiKey: string;
  1102. baseUrl?: string;
  1103. apiFormat?: "chat" | "responses";
  1104. stream?: boolean;
  1105. }>();
  1106. if (!apiKey?.trim()) {
  1107. return c.json({ ok: false, error: "API Key 不能为空" }, 400);
  1108. }
  1109. const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service, baseUrl);
  1110. if (!resolvedBaseUrl) {
  1111. return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
  1112. }
  1113. const probe = await probeServiceCapabilities({
  1114. root,
  1115. service,
  1116. apiKey: apiKey.trim(),
  1117. baseUrl: resolvedBaseUrl,
  1118. preferredApiFormat: apiFormat,
  1119. preferredStream: stream,
  1120. });
  1121. // B12: 升级响应 shape 为 { probe, chat, ... },同时保留老字段供 UI 过渡期兼容
  1122. const probeStatus = {
  1123. ok: probe.ok,
  1124. models: probe.models?.length ?? 0,
  1125. ...(probe.ok ? {} : { error: probe.error ?? "连接失败" }),
  1126. };
  1127. if (!probe.ok) {
  1128. return c.json({
  1129. ok: false,
  1130. error: probe.error ?? "连接失败",
  1131. probe: probeStatus,
  1132. chat: null,
  1133. }, 400);
  1134. }
  1135. return c.json({
  1136. ok: true,
  1137. modelCount: probe.models.length,
  1138. models: probe.models,
  1139. selectedModel: probe.selectedModel,
  1140. detected: {
  1141. apiFormat: probe.apiFormat,
  1142. stream: probe.stream,
  1143. baseUrl: probe.baseUrl,
  1144. modelsSource: probe.modelsSource,
  1145. },
  1146. // B12 新字段:两步验证状态
  1147. probe: probeStatus,
  1148. chat: null, // probeServiceCapabilities 本身只做 probe,chat hello 在 Studio 的 follow-up 调用里单独触发
  1149. });
  1150. });
  1151. app.put("/api/v1/services/:service/secret", async (c) => {
  1152. const service = c.req.param("service");
  1153. const { apiKey } = await c.req.json<{ apiKey: string }>();
  1154. const secrets = await loadSecrets(root);
  1155. if (apiKey?.trim()) {
  1156. secrets.services[service] = { apiKey: apiKey.trim() };
  1157. } else {
  1158. delete secrets.services[service];
  1159. }
  1160. await saveSecrets(root, secrets);
  1161. return c.json({ ok: true });
  1162. });
  1163. app.get("/api/v1/services/:service/secret", async (c) => {
  1164. const service = c.req.param("service");
  1165. const secrets = await loadSecrets(root);
  1166. return c.json({
  1167. apiKey: secrets.services[service]?.apiKey ?? "",
  1168. });
  1169. });
  1170. app.get("/api/v1/services/models", async (c) => {
  1171. const secrets = await loadSecrets(root);
  1172. const endpoints = getAllEndpoints()
  1173. .filter((ep) => ep.id !== "custom" && Boolean(secrets.services[ep.id]?.apiKey));
  1174. const groups = endpoints.map((ep) => ({
  1175. service: ep.id,
  1176. label: ep.label,
  1177. models: ep.models
  1178. .filter((m) => m.enabled !== false)
  1179. .filter((m) => isTextChatModelId(m.id))
  1180. .map((m) => ({
  1181. id: m.id,
  1182. name: m.id,
  1183. ...(typeof m.maxOutput === "number" ? { maxOutput: m.maxOutput } : {}),
  1184. ...(m.contextWindowTokens > 0 ? { contextWindow: m.contextWindowTokens } : {}),
  1185. })),
  1186. }));
  1187. return c.json({ groups });
  1188. });
  1189. app.get("/api/v1/services/models/custom", async (c) => {
  1190. const secrets = await loadSecrets(root);
  1191. let config: Record<string, unknown> = {};
  1192. try {
  1193. config = await loadRawConfig(root);
  1194. } catch {
  1195. // no config file
  1196. }
  1197. const customs = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services)
  1198. .filter((s) => s.service === "custom")
  1199. .map((s) => ({
  1200. id: `custom:${s.name ?? "Custom"}`,
  1201. baseUrl: s.baseUrl ?? "",
  1202. label: s.name ?? "Custom",
  1203. }))
  1204. .filter((s) => s.baseUrl && Boolean(secrets.services[s.id]?.apiKey));
  1205. const groups = await Promise.all(customs.map(async (s) => ({
  1206. service: s.id,
  1207. label: s.label,
  1208. models: filterTextChatModels(
  1209. await probeModelsFromUpstream(s.baseUrl, secrets.services[s.id].apiKey, 10_000),
  1210. ),
  1211. })));
  1212. return c.json({ groups });
  1213. });
  1214. app.get("/api/v1/services/:service/models", async (c) => {
  1215. const service = c.req.param("service");
  1216. const refresh = c.req.query("refresh") === "1";
  1217. const secrets = await loadSecrets(root);
  1218. const apiKey = c.req.query("apiKey") || secrets.services[service]?.apiKey || "";
  1219. // No key = no models
  1220. if (!apiKey) return c.json({ models: [] });
  1221. const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
  1222. // Cache by service + resolved baseUrl + apiKey fingerprint; valid for 10 min unless ?refresh=1
  1223. const cacheKey = `${service}::${resolvedBaseUrl ?? ""}::${apiKey.slice(-8)}`;
  1224. if (!refresh) {
  1225. const cached = modelListCache.get(cacheKey);
  1226. if (cached && Date.now() - cached.at < 10 * 60 * 1000) {
  1227. return c.json({ models: cached.models });
  1228. }
  1229. }
  1230. // B13: 走 listModelsForService 走 live probe + bank 交叉,返回带元数据的 models
  1231. const enriched = await listModelsForService(
  1232. isCustomServiceId(service) ? "custom" : service,
  1233. apiKey,
  1234. isCustomServiceId(service) ? resolvedBaseUrl ?? undefined : undefined,
  1235. );
  1236. const models = filterTextChatModels(enriched).map((m) => ({
  1237. id: m.id,
  1238. name: m.name,
  1239. ...(m.maxOutput !== undefined ? { maxOutput: m.maxOutput } : {}),
  1240. ...(m.contextWindow > 0 ? { contextWindow: m.contextWindow } : {}),
  1241. }));
  1242. modelListCache.set(cacheKey, { models, at: Date.now() });
  1243. return c.json({ models });
  1244. });
  1245. // --- Project info ---
  1246. app.get("/api/v1/project", async (c) => {
  1247. const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
  1248. // Check if language was explicitly set in inkos.json (not just the schema default)
  1249. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
  1250. const languageExplicit = "language" in raw && raw.language !== "";
  1251. return c.json({
  1252. name: currentConfig.name,
  1253. language: currentConfig.language,
  1254. languageExplicit,
  1255. model: currentConfig.llm.model,
  1256. provider: currentConfig.llm.provider,
  1257. baseUrl: currentConfig.llm.baseUrl,
  1258. stream: currentConfig.llm.stream,
  1259. temperature: currentConfig.llm.temperature,
  1260. });
  1261. });
  1262. // --- Config editing ---
  1263. app.put("/api/v1/project", async (c) => {
  1264. const updates = await c.req.json<Record<string, unknown>>();
  1265. const configPath = join(root, "inkos.json");
  1266. try {
  1267. const raw = await readFile(configPath, "utf-8");
  1268. const existing = JSON.parse(raw);
  1269. // Merge LLM settings
  1270. if (updates.temperature !== undefined) {
  1271. existing.llm.temperature = updates.temperature;
  1272. }
  1273. if (updates.stream !== undefined) {
  1274. existing.llm.stream = updates.stream;
  1275. }
  1276. if (updates.language === "zh" || updates.language === "en") {
  1277. existing.language = updates.language;
  1278. }
  1279. const { writeFile: writeFileFs } = await import("node:fs/promises");
  1280. await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
  1281. return c.json({ ok: true });
  1282. } catch (e) {
  1283. return c.json({ error: String(e) }, 500);
  1284. }
  1285. });
  1286. // --- Truth files browser ---
  1287. app.get("/api/v1/books/:id/truth", async (c) => {
  1288. const id = c.req.param("id");
  1289. const bookDir = state.bookDir(id);
  1290. const storyDir = join(bookDir, "story");
  1291. async function listDir(subdir: string): Promise<string[]> {
  1292. try {
  1293. const entries = await readdir(join(storyDir, subdir));
  1294. return entries.filter((f) => f.endsWith(".md") || f.endsWith(".json"));
  1295. } catch {
  1296. return [];
  1297. }
  1298. }
  1299. // Hotfix: only tag shim files as legacy when the book has the new layout.
  1300. const { isNewLayoutBook } = await import("@actalk/inkos-core");
  1301. const newLayout = await isNewLayoutBook(bookDir);
  1302. async function describe(relPath: string): Promise<{ readonly name: string; readonly size: number; readonly preview: string; readonly legacy?: true } | null> {
  1303. try {
  1304. const content = await readFile(join(storyDir, relPath), "utf-8");
  1305. const isShim = LEGACY_SHIM_FILES.has(relPath) && newLayout;
  1306. const entry: { readonly name: string; readonly size: number; readonly preview: string; readonly legacy?: true } =
  1307. isShim
  1308. ? { name: relPath, size: content.length, preview: content.slice(0, 200), legacy: true }
  1309. : { name: relPath, size: content.length, preview: content.slice(0, 200) };
  1310. return entry;
  1311. } catch {
  1312. return null;
  1313. }
  1314. }
  1315. try {
  1316. // Flat story/ files (legacy + runtime logs)
  1317. const flatFiles = (await listDir(".")).filter((f) => !f.startsWith("outline") && !f.startsWith("roles"));
  1318. // Phase 5 outline/ files
  1319. const outlineFiles = (await listDir("outline")).map((f) => `outline/${f}`);
  1320. // Phase 5 roles/主要角色 + roles/次要角色, plus Phase hotfix 3
  1321. // English-locale equivalents so en-language books are visible.
  1322. const majorRolesZh = (await listDir("roles/主要角色")).map((f) => `roles/主要角色/${f}`);
  1323. const minorRolesZh = (await listDir("roles/次要角色")).map((f) => `roles/次要角色/${f}`);
  1324. const majorRolesEn = (await listDir("roles/major")).map((f) => `roles/major/${f}`);
  1325. const minorRolesEn = (await listDir("roles/minor")).map((f) => `roles/minor/${f}`);
  1326. const all = [
  1327. ...flatFiles,
  1328. ...outlineFiles,
  1329. ...majorRolesZh,
  1330. ...minorRolesZh,
  1331. ...majorRolesEn,
  1332. ...minorRolesEn,
  1333. ];
  1334. const described = await Promise.all(all.map(describe));
  1335. const result = described.filter((x): x is NonNullable<typeof x> => x !== null);
  1336. return c.json({ files: result });
  1337. } catch {
  1338. return c.json({ files: [] });
  1339. }
  1340. });
  1341. // --- Daemon control ---
  1342. let schedulerInstance: import("@actalk/inkos-core").Scheduler | null = null;
  1343. app.get("/api/v1/daemon", (c) => {
  1344. return c.json({
  1345. running: schedulerInstance?.isRunning ?? false,
  1346. });
  1347. });
  1348. app.post("/api/v1/daemon/start", async (c) => {
  1349. if (schedulerInstance?.isRunning) {
  1350. return c.json({ error: "Daemon already running" }, 400);
  1351. }
  1352. try {
  1353. const { Scheduler } = await import("@actalk/inkos-core");
  1354. const currentConfig = await loadCurrentProjectConfig();
  1355. const scheduler = new Scheduler({
  1356. ...(await buildPipelineConfig()),
  1357. radarCron: currentConfig.daemon.schedule.radarCron,
  1358. writeCron: currentConfig.daemon.schedule.writeCron,
  1359. maxConcurrentBooks: currentConfig.daemon.maxConcurrentBooks,
  1360. chaptersPerCycle: currentConfig.daemon.chaptersPerCycle,
  1361. retryDelayMs: currentConfig.daemon.retryDelayMs,
  1362. cooldownAfterChapterMs: currentConfig.daemon.cooldownAfterChapterMs,
  1363. maxChaptersPerDay: currentConfig.daemon.maxChaptersPerDay,
  1364. onChapterComplete: (bookId, chapter, status) => {
  1365. broadcast("daemon:chapter", { bookId, chapter, status });
  1366. },
  1367. onError: (bookId, error) => {
  1368. broadcast("daemon:error", { bookId, error: error.message });
  1369. },
  1370. });
  1371. schedulerInstance = scheduler;
  1372. broadcast("daemon:started", {});
  1373. void scheduler.start().catch((e) => {
  1374. const error = e instanceof Error ? e : new Error(String(e));
  1375. if (schedulerInstance === scheduler) {
  1376. scheduler.stop();
  1377. schedulerInstance = null;
  1378. broadcast("daemon:stopped", {});
  1379. }
  1380. broadcast("daemon:error", { bookId: "scheduler", error: error.message });
  1381. });
  1382. return c.json({ ok: true, running: true });
  1383. } catch (e) {
  1384. return c.json({ error: String(e) }, 500);
  1385. }
  1386. });
  1387. app.post("/api/v1/daemon/stop", (c) => {
  1388. if (!schedulerInstance?.isRunning) {
  1389. return c.json({ error: "Daemon not running" }, 400);
  1390. }
  1391. schedulerInstance.stop();
  1392. schedulerInstance = null;
  1393. broadcast("daemon:stopped", {});
  1394. return c.json({ ok: true, running: false });
  1395. });
  1396. // --- Logs ---
  1397. app.get("/api/v1/logs", async (c) => {
  1398. const logPath = join(root, "inkos.log");
  1399. try {
  1400. const content = await readFile(logPath, "utf-8");
  1401. const lines = content.trim().split("\n").slice(-100);
  1402. const entries = lines.map((line) => {
  1403. try { return JSON.parse(line); } catch { return { message: line }; }
  1404. });
  1405. return c.json({ entries });
  1406. } catch {
  1407. return c.json({ entries: [] });
  1408. }
  1409. });
  1410. // --- Agent chat ---
  1411. app.get("/api/v1/interaction/session", async (c) => {
  1412. const session = await loadProjectSession(root);
  1413. const activeBookId = await resolveSessionActiveBook(root, session);
  1414. return c.json({
  1415. session: activeBookId && session.activeBookId !== activeBookId
  1416. ? { ...session, activeBookId }
  1417. : session,
  1418. activeBookId,
  1419. });
  1420. });
  1421. // -- Per-book session endpoints --
  1422. app.get("/api/v1/sessions", async (c) => {
  1423. const bookId = c.req.query("bookId");
  1424. const sessions = await listBookSessions(root, bookId === undefined ? null : bookId === "null" ? null : bookId);
  1425. return c.json({ sessions });
  1426. });
  1427. app.get("/api/v1/sessions/:sessionId", async (c) => {
  1428. const session = await loadBookSession(root, c.req.param("sessionId"));
  1429. if (!session) return c.json({ error: "Session not found" }, 404);
  1430. return c.json({ session });
  1431. });
  1432. app.post("/api/v1/sessions", async (c) => {
  1433. const body = await c.req.json<{ bookId?: string | null; sessionId?: string }>().catch(() => ({}));
  1434. const bookId = (body as { bookId?: string | null }).bookId ?? null;
  1435. const sessionId = (body as { sessionId?: string }).sessionId;
  1436. // sessionId 只允许 timestamp-random 格式;防止注入任意文件名
  1437. const safeSessionId = sessionId && /^[0-9]+-[a-z0-9]+$/.test(sessionId) ? sessionId : undefined;
  1438. const session = await createAndPersistBookSession(root, bookId, safeSessionId);
  1439. return c.json({ session });
  1440. });
  1441. app.put("/api/v1/sessions/:sessionId", async (c) => {
  1442. const sessionId = c.req.param("sessionId");
  1443. const body = await c.req.json<{ title?: string }>().catch(() => ({}) as { title?: string });
  1444. const title = body.title?.trim();
  1445. if (!title) {
  1446. throw new ApiError(400, "INVALID_SESSION_TITLE", "Session title is required");
  1447. }
  1448. const session = await renameBookSession(root, sessionId, title);
  1449. if (!session) {
  1450. return c.json({ error: "Session not found" }, 404);
  1451. }
  1452. return c.json({ session });
  1453. });
  1454. app.delete("/api/v1/sessions/:sessionId", async (c) => {
  1455. await deleteBookSession(root, c.req.param("sessionId"));
  1456. return c.json({ ok: true });
  1457. });
  1458. app.post("/api/v1/agent", async (c) => {
  1459. const { instruction, activeBookId, sessionId: reqSessionId, model: reqModel, service: reqService } = await c.req.json<{
  1460. instruction: string;
  1461. activeBookId?: string;
  1462. sessionId?: string;
  1463. model?: string;
  1464. service?: string;
  1465. }>();
  1466. const sessionId = reqSessionId;
  1467. if (!instruction?.trim()) {
  1468. return c.json({ error: "No instruction provided" }, 400);
  1469. }
  1470. if (!sessionId?.trim()) {
  1471. throw new ApiError(400, "SESSION_ID_REQUIRED", "sessionId is required");
  1472. }
  1473. if (reqModel && !isTextChatModelId(reqModel)) {
  1474. const message = nonTextModelMessage(reqModel);
  1475. return c.json({ error: message, response: message }, 400);
  1476. }
  1477. broadcast("agent:start", { instruction, activeBookId, sessionId });
  1478. try {
  1479. // Load config + create LLM client (pipeline created after model resolution)
  1480. const config = await loadCurrentProjectConfig({ requireApiKey: false });
  1481. const client = createLLMClient(config.llm);
  1482. const loadedBookSession = await loadBookSession(root, sessionId);
  1483. if (!loadedBookSession) {
  1484. throw new ApiError(404, "SESSION_NOT_FOUND", `Session not found: ${sessionId}`);
  1485. }
  1486. let bookSession = loadedBookSession;
  1487. const streamSessionId = loadedBookSession.sessionId;
  1488. const titleBeforeRun = bookSession.title;
  1489. let sessionTitleBroadcasted = false;
  1490. const refreshBookSessionFromTranscript = async (): Promise<void> => {
  1491. const refreshed = await loadBookSession(root, bookSession.sessionId);
  1492. if (refreshed) {
  1493. bookSession = refreshed;
  1494. }
  1495. if (!sessionTitleBroadcasted && titleBeforeRun === null && bookSession.title) {
  1496. broadcast("session:title", { sessionId: bookSession.sessionId, title: bookSession.title });
  1497. sessionTitleBroadcasted = true;
  1498. }
  1499. };
  1500. // Resolve model — multi-service resolution
  1501. let resolvedModel: ResolvedModel["model"] | undefined;
  1502. let resolvedApiKey: string | undefined;
  1503. if (reqService && reqModel) {
  1504. // 1. Frontend explicitly selected a service+model — fail loudly if no key
  1505. try {
  1506. const configuredEntry = await resolveConfiguredServiceEntry(root, reqService);
  1507. const resolved = await resolveServiceModel(
  1508. reqService,
  1509. reqModel,
  1510. root,
  1511. await resolveConfiguredServiceBaseUrl(root, reqService),
  1512. configuredEntry?.apiFormat,
  1513. );
  1514. resolvedModel = resolved.model;
  1515. resolvedApiKey = resolved.apiKey;
  1516. } catch (e: any) {
  1517. const msg = e?.message ?? String(e);
  1518. if (/API key/i.test(msg)) {
  1519. return c.json({
  1520. error: `请先为 ${reqService} 配置 API Key`,
  1521. response: `请先在模型配置中为 ${reqService} 填写 API Key,然后再试。`,
  1522. }, 400);
  1523. }
  1524. throw e;
  1525. }
  1526. }
  1527. if (!resolvedModel) {
  1528. // 2. Try defaultModel from new config format
  1529. const rawConfig = config.llm as unknown as Record<string, unknown>;
  1530. const defaultModel = rawConfig.defaultModel as string | undefined;
  1531. const servicesArr = normalizeServiceConfig(rawConfig.services);
  1532. const firstService = servicesArr[0];
  1533. if (firstService?.service && defaultModel && isTextChatModelId(defaultModel)) {
  1534. try {
  1535. const resolved = await resolveServiceModel(
  1536. serviceConfigKey(firstService),
  1537. defaultModel,
  1538. root,
  1539. firstService.baseUrl,
  1540. firstService.apiFormat,
  1541. );
  1542. resolvedModel = resolved.model;
  1543. resolvedApiKey = resolved.apiKey;
  1544. } catch { /* fall through */ }
  1545. }
  1546. }
  1547. if (!resolvedModel) {
  1548. // 3. Try first connected service from secrets
  1549. const secrets = await loadSecrets(root);
  1550. for (const [svcName, svcData] of Object.entries(secrets.services)) {
  1551. if (svcData?.apiKey) {
  1552. try {
  1553. const models = await listModelsForService(svcName, svcData.apiKey);
  1554. const textModels = filterTextChatModels(models);
  1555. if (textModels.length > 0) {
  1556. const configuredEntry = await resolveConfiguredServiceEntry(root, svcName);
  1557. const resolved = await resolveServiceModel(
  1558. svcName,
  1559. textModels[0].id,
  1560. root,
  1561. await resolveConfiguredServiceBaseUrl(root, svcName),
  1562. configuredEntry?.apiFormat,
  1563. );
  1564. resolvedModel = resolved.model;
  1565. resolvedApiKey = resolved.apiKey;
  1566. break;
  1567. }
  1568. } catch { /* try next */ }
  1569. }
  1570. }
  1571. }
  1572. if (!resolvedModel) {
  1573. // 4. Legacy fallback: use createLLMClient
  1574. resolvedModel = client._piModel
  1575. ? client._piModel
  1576. : { provider: config.llm.provider ?? "anthropic", modelId: config.llm.model } as any;
  1577. resolvedApiKey = client._apiKey;
  1578. }
  1579. const model = resolvedModel!;
  1580. const agentApiKey = resolvedApiKey;
  1581. const configuredEntry = reqService ? await resolveConfiguredServiceEntry(root, reqService) : undefined;
  1582. // Create pipeline with resolved model (so sub_agent tools use the frontend-selected model)
  1583. // Don't spread config.llm — its baseUrl/provider belong to the old service.
  1584. // Let createLLMClient resolve baseUrl from the service preset.
  1585. const pipelineClient = (reqService && reqModel && resolvedApiKey)
  1586. ? createLLMClient({
  1587. ...config.llm,
  1588. service: configuredEntry?.service ?? reqService,
  1589. model: reqModel,
  1590. apiKey: resolvedApiKey,
  1591. ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
  1592. ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
  1593. baseUrl: configuredEntry?.baseUrl ?? "",
  1594. } as any)
  1595. : client;
  1596. const pipeline = new PipelineRunner(await buildPipelineConfig({
  1597. client: pipelineClient,
  1598. model: reqModel ?? config.llm.model,
  1599. currentConfig: config,
  1600. sessionIdForSSE: bookSession.sessionId,
  1601. }));
  1602. // Run pi-agent session
  1603. const collectedToolExecs: CollectedToolExec[] = [];
  1604. const result = await runAgentSession(
  1605. {
  1606. model,
  1607. apiKey: agentApiKey,
  1608. pipeline,
  1609. projectRoot: root,
  1610. bookId: activeBookId ?? null,
  1611. sessionId: bookSession.sessionId,
  1612. language: config.language ?? "zh",
  1613. onEvent: (event) => {
  1614. if (event.type === "message_update") {
  1615. const ame = event.assistantMessageEvent;
  1616. if (ame.type === "text_delta") {
  1617. broadcast("draft:delta", { sessionId: streamSessionId, text: ame.delta });
  1618. } else if (ame.type === "thinking_delta") {
  1619. broadcast("thinking:delta", { sessionId: streamSessionId, text: (ame as any).delta });
  1620. } else if (ame.type === "thinking_start") {
  1621. broadcast("thinking:start", { sessionId: streamSessionId });
  1622. } else if (ame.type === "thinking_end") {
  1623. broadcast("thinking:end", { sessionId: streamSessionId });
  1624. }
  1625. }
  1626. if (event.type === "tool_execution_start") {
  1627. const args = event.args as Record<string, unknown> | undefined;
  1628. const agent = event.toolName === "sub_agent" ? (args?.agent as string | undefined) : undefined;
  1629. const stages = agent ? (PIPELINE_STAGES[agent] ?? []) : [];
  1630. collectedToolExecs.push({
  1631. id: event.toolCallId,
  1632. tool: event.toolName,
  1633. agent,
  1634. label: resolveToolLabel(event.toolName, agent),
  1635. status: "running",
  1636. args,
  1637. stages: stages.length > 0
  1638. ? stages.map(l => ({ label: l, status: "pending" as const }))
  1639. : undefined,
  1640. startedAt: Date.now(),
  1641. });
  1642. if (!activeBookId && event.toolName === "sub_agent" && agent === "architect") {
  1643. const bookId = resolveArchitectBookIdFromArgs(args);
  1644. if (bookId) {
  1645. const title = typeof args?.title === "string" && args.title.trim()
  1646. ? args.title.trim()
  1647. : bookId;
  1648. bookCreateStatus.set(bookId, { status: "creating" });
  1649. broadcast("book:creating", { bookId, title, sessionId: streamSessionId });
  1650. }
  1651. }
  1652. broadcast("tool:start", {
  1653. sessionId: streamSessionId,
  1654. id: event.toolCallId,
  1655. tool: event.toolName,
  1656. args,
  1657. stages,
  1658. });
  1659. }
  1660. if (event.type === "tool_execution_update") {
  1661. broadcast("tool:update", {
  1662. sessionId: streamSessionId,
  1663. tool: event.toolName,
  1664. partialResult: event.partialResult,
  1665. });
  1666. }
  1667. if (event.type === "tool_execution_end") {
  1668. const exec = collectedToolExecs.find(t => t.id === event.toolCallId);
  1669. if (exec) {
  1670. exec.status = event.isError ? "error" : "completed";
  1671. exec.completedAt = Date.now();
  1672. exec.stages = exec.stages?.map(s => ({ ...s, status: "completed" as const }));
  1673. if (event.isError) exec.error = extractToolError(event.result);
  1674. else exec.result = summarizeResult(event.result);
  1675. exec.details = (event.result as { details?: unknown } | undefined)?.details;
  1676. if (
  1677. event.isError &&
  1678. !activeBookId &&
  1679. exec.tool === "sub_agent" &&
  1680. exec.agent === "architect"
  1681. ) {
  1682. const bookId = resolveArchitectBookIdFromArgs(exec.args);
  1683. if (bookId) {
  1684. const error = exec.error ?? "Book creation failed";
  1685. bookCreateStatus.set(bookId, { status: "error", error });
  1686. broadcast("book:error", { bookId, sessionId: streamSessionId, error });
  1687. }
  1688. }
  1689. }
  1690. broadcast("tool:end", {
  1691. sessionId: streamSessionId,
  1692. id: event.toolCallId,
  1693. tool: event.toolName,
  1694. result: event.result,
  1695. isError: event.isError,
  1696. });
  1697. }
  1698. },
  1699. },
  1700. instruction,
  1701. );
  1702. let broadcastedCreatedBookId: string | null = null;
  1703. const finalizeCreatedBook = async (): Promise<string | null> => {
  1704. if (activeBookId) return null;
  1705. const createdBookId = resolveCreatedBookIdFromToolExecs(collectedToolExecs);
  1706. if (!createdBookId) return null;
  1707. if (broadcastedCreatedBookId === createdBookId) return createdBookId;
  1708. try {
  1709. const migratedSession = await migrateBookSession(root, bookSession.sessionId, createdBookId);
  1710. if (migratedSession) {
  1711. bookSession = migratedSession;
  1712. }
  1713. } catch (e) {
  1714. if (!(e instanceof SessionAlreadyMigratedError)) {
  1715. throw e;
  1716. }
  1717. }
  1718. const book = await loadStudioBookListSummary(state, createdBookId).catch(() => undefined);
  1719. bookCreateStatus.delete(createdBookId);
  1720. broadcast("book:created", {
  1721. bookId: createdBookId,
  1722. sessionId: bookSession.sessionId,
  1723. ...(book ? { book } : {}),
  1724. });
  1725. broadcastedCreatedBookId = createdBookId;
  1726. return createdBookId;
  1727. };
  1728. if (!result.responseText) {
  1729. if (result.errorMessage) {
  1730. if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
  1731. await finalizeCreatedBook();
  1732. }
  1733. return c.json({
  1734. error: { code: "AGENT_LLM_ERROR", message: result.errorMessage },
  1735. response: result.errorMessage,
  1736. }, 502);
  1737. }
  1738. try {
  1739. const fallbackClient = createLLMClient({
  1740. ...config.llm,
  1741. service: configuredEntry?.service ?? reqService ?? config.llm.service,
  1742. model: reqModel ?? config.llm.model,
  1743. apiKey: agentApiKey ?? config.llm.apiKey,
  1744. baseUrl: configuredEntry?.baseUrl ?? "",
  1745. ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
  1746. ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
  1747. } as ProjectConfig["llm"]);
  1748. const fallback = await chatCompletion(
  1749. fallbackClient,
  1750. reqModel ?? config.llm.model,
  1751. [
  1752. { role: "system", content: buildAgentSystemPrompt(activeBookId ?? null, config.language ?? "zh") },
  1753. { role: "user", content: instruction },
  1754. ],
  1755. { maxTokens: 256 },
  1756. );
  1757. if (fallback.content?.trim()) {
  1758. await appendManualSessionMessages(root, bookSession.sessionId, [{
  1759. role: "assistant",
  1760. content: [{ type: "text", text: fallback.content }],
  1761. api: "anthropic-messages",
  1762. provider: configuredEntry?.service ?? reqService ?? config.llm.provider,
  1763. model: reqModel ?? config.llm.model,
  1764. usage: {
  1765. input: 0,
  1766. output: 0,
  1767. cacheRead: 0,
  1768. cacheWrite: 0,
  1769. totalTokens: 0,
  1770. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
  1771. },
  1772. stopReason: "stop",
  1773. timestamp: Date.now(),
  1774. }], instruction);
  1775. await refreshBookSessionFromTranscript();
  1776. const createdBookId = await finalizeCreatedBook();
  1777. return c.json({
  1778. response: fallback.content,
  1779. session: {
  1780. sessionId: bookSession.sessionId,
  1781. ...(createdBookId ? { activeBookId: createdBookId } : {}),
  1782. },
  1783. });
  1784. }
  1785. } catch {
  1786. // fall through to probe-based diagnosis below
  1787. }
  1788. try {
  1789. const probeClient = createLLMClient({
  1790. ...config.llm,
  1791. service: configuredEntry?.service ?? reqService ?? config.llm.service,
  1792. model: reqModel ?? config.llm.model,
  1793. apiKey: agentApiKey ?? config.llm.apiKey,
  1794. baseUrl: configuredEntry?.baseUrl ?? "",
  1795. ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
  1796. ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
  1797. } as ProjectConfig["llm"]);
  1798. await chatCompletion(
  1799. probeClient,
  1800. reqModel ?? config.llm.model,
  1801. [{ role: "user", content: "ping" }],
  1802. { maxTokens: 5 },
  1803. );
  1804. } catch (probeError) {
  1805. const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
  1806. if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
  1807. await finalizeCreatedBook();
  1808. }
  1809. return c.json({
  1810. error: { code: "AGENT_EMPTY_RESPONSE", message: probeMessage },
  1811. response: probeMessage,
  1812. }, 502);
  1813. }
  1814. const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
  1815. if (resolveCreatedBookIdFromToolExecs(collectedToolExecs)) {
  1816. await finalizeCreatedBook();
  1817. }
  1818. return c.json({
  1819. error: { code: "AGENT_EMPTY_RESPONSE", message: emptyMessage },
  1820. response: emptyMessage,
  1821. }, 502);
  1822. }
  1823. await refreshBookSessionFromTranscript();
  1824. await finalizeCreatedBook();
  1825. broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId });
  1826. return c.json({
  1827. response: result.responseText,
  1828. session: {
  1829. sessionId: bookSession.sessionId,
  1830. ...(bookSession.bookId ? { activeBookId: bookSession.bookId } : {}),
  1831. },
  1832. });
  1833. } catch (e) {
  1834. if (e instanceof ApiError) {
  1835. throw e;
  1836. }
  1837. if (e instanceof SessionAlreadyMigratedError) {
  1838. const migratedMessage = e instanceof Error ? e.message : String(e);
  1839. throw new ApiError(409, "SESSION_ALREADY_MIGRATED", migratedMessage);
  1840. }
  1841. const msg = e instanceof Error ? e.message : String(e);
  1842. broadcast("agent:error", { instruction, activeBookId, sessionId, error: msg });
  1843. // Agent busy — return 429 with user-friendly message
  1844. if (/already processing|prompt.*queue/i.test(msg)) {
  1845. return c.json({
  1846. error: { code: "AGENT_BUSY", message: "正在处理中,请等待当前操作完成" },
  1847. response: "正在处理中,请等待当前操作完成后再发送。",
  1848. }, 429);
  1849. }
  1850. return c.json(
  1851. { error: { code: "AGENT_ERROR", message: msg } },
  1852. 500,
  1853. );
  1854. }
  1855. });
  1856. // --- Language setup ---
  1857. app.post("/api/v1/project/language", async (c) => {
  1858. const { language } = await c.req.json<{ language: "zh" | "en" }>();
  1859. const configPath = join(root, "inkos.json");
  1860. try {
  1861. const raw = await readFile(configPath, "utf-8");
  1862. const existing = JSON.parse(raw);
  1863. existing.language = language;
  1864. const { writeFile: writeFileFs } = await import("node:fs/promises");
  1865. await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
  1866. return c.json({ ok: true, language });
  1867. } catch (e) {
  1868. return c.json({ error: String(e) }, 500);
  1869. }
  1870. });
  1871. // --- Audit ---
  1872. app.post("/api/v1/books/:id/audit/:chapter", async (c) => {
  1873. const id = c.req.param("id");
  1874. const chapterNum = parseInt(c.req.param("chapter"), 10);
  1875. const bookDir = state.bookDir(id);
  1876. broadcast("audit:start", { bookId: id, chapter: chapterNum });
  1877. try {
  1878. const book = await state.loadBookConfig(id);
  1879. const chaptersDir = join(bookDir, "chapters");
  1880. const files = await readdir(chaptersDir);
  1881. const paddedNum = String(chapterNum).padStart(4, "0");
  1882. const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  1883. if (!match) return c.json({ error: "Chapter not found" }, 404);
  1884. const content = await readFile(join(chaptersDir, match), "utf-8");
  1885. const currentConfig = await loadCurrentProjectConfig();
  1886. const { ContinuityAuditor } = await import("@actalk/inkos-core");
  1887. const auditor = new ContinuityAuditor({
  1888. client: createLLMClient(currentConfig.llm),
  1889. model: currentConfig.llm.model,
  1890. projectRoot: root,
  1891. bookId: id,
  1892. });
  1893. const result = await auditor.auditChapter(bookDir, content, chapterNum, book.genre);
  1894. broadcast("audit:complete", { bookId: id, chapter: chapterNum, passed: result.passed });
  1895. return c.json(result);
  1896. } catch (e) {
  1897. broadcast("audit:error", { bookId: id, error: String(e) });
  1898. return c.json({ error: String(e) }, 500);
  1899. }
  1900. });
  1901. // --- Revise ---
  1902. app.post("/api/v1/books/:id/revise/:chapter", async (c) => {
  1903. const id = c.req.param("id");
  1904. const chapterNum = parseInt(c.req.param("chapter"), 10);
  1905. const bookDir = state.bookDir(id);
  1906. const body = await c.req
  1907. .json<{ mode?: string; brief?: string }>()
  1908. .catch(() => ({ mode: "spot-fix", brief: undefined }));
  1909. broadcast("revise:start", { bookId: id, chapter: chapterNum });
  1910. try {
  1911. const book = await state.loadBookConfig(id);
  1912. const chaptersDir = join(bookDir, "chapters");
  1913. const files = await readdir(chaptersDir);
  1914. const paddedNum = String(chapterNum).padStart(4, "0");
  1915. const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  1916. if (!match) return c.json({ error: "Chapter not found" }, 404);
  1917. const pipeline = new PipelineRunner(await buildPipelineConfig({
  1918. externalContext: body.brief,
  1919. }));
  1920. const normalizedMode = body.mode ?? "spot-fix";
  1921. const result = await pipeline.reviseDraft(
  1922. id,
  1923. chapterNum,
  1924. normalizedMode as "polish" | "rewrite" | "rework" | "spot-fix" | "anti-detect",
  1925. );
  1926. broadcast("revise:complete", { bookId: id, chapter: chapterNum });
  1927. return c.json(result);
  1928. } catch (e) {
  1929. broadcast("revise:error", { bookId: id, error: String(e) });
  1930. return c.json({ error: String(e) }, 500);
  1931. }
  1932. });
  1933. // --- Export ---
  1934. app.get("/api/v1/books/:id/export", async (c) => {
  1935. const id = c.req.param("id");
  1936. const format = (c.req.query("format") ?? "txt") as string;
  1937. const approvedOnly = c.req.query("approvedOnly") === "true";
  1938. try {
  1939. const artifact = await buildExportArtifact(state, id, {
  1940. format: format as "txt" | "md" | "epub",
  1941. approvedOnly,
  1942. });
  1943. const responseBody = typeof artifact.payload === "string"
  1944. ? artifact.payload
  1945. : new Uint8Array(artifact.payload);
  1946. return new Response(responseBody, {
  1947. headers: {
  1948. "Content-Type": artifact.contentType,
  1949. "Content-Disposition": `attachment; filename="${artifact.fileName}"`,
  1950. },
  1951. });
  1952. } catch {
  1953. return c.json({ error: "Export failed" }, 500);
  1954. }
  1955. });
  1956. // --- Export to file (save to project dir) ---
  1957. app.post("/api/v1/books/:id/export-save", async (c) => {
  1958. const id = c.req.param("id");
  1959. const { format, approvedOnly } = await c.req.json<{ format?: string; approvedOnly?: boolean }>().catch(() => ({ format: "txt", approvedOnly: false }));
  1960. const fmt = format ?? "txt";
  1961. try {
  1962. const pipeline = new PipelineRunner(await buildPipelineConfig());
  1963. const tools = createInteractionToolsFromDeps(pipeline, state);
  1964. const bookDir = state.bookDir(id);
  1965. const outputPath = join(bookDir, `${id}.${fmt === "epub" ? "epub" : fmt}`);
  1966. const result = await processProjectInteractionRequest({
  1967. projectRoot: root,
  1968. request: {
  1969. intent: "export_book",
  1970. bookId: id,
  1971. format: fmt as "txt" | "md" | "epub",
  1972. approvedOnly,
  1973. outputPath,
  1974. },
  1975. tools,
  1976. activeBookId: id,
  1977. });
  1978. return c.json({
  1979. ok: true,
  1980. path: (result.details?.outputPath as string | undefined) ?? outputPath,
  1981. format: fmt,
  1982. chapters: (result.details?.chaptersExported as number | undefined) ?? 0,
  1983. });
  1984. } catch (e) {
  1985. return c.json({ error: String(e) }, 500);
  1986. }
  1987. });
  1988. // --- Genre detail + copy ---
  1989. app.get("/api/v1/genres/:id", async (c) => {
  1990. const genreId = c.req.param("id");
  1991. try {
  1992. const { readGenreProfile } = await import("@actalk/inkos-core");
  1993. const { profile, body } = await readGenreProfile(root, genreId);
  1994. return c.json({ profile, body });
  1995. } catch (e) {
  1996. return c.json({ error: String(e) }, 404);
  1997. }
  1998. });
  1999. app.post("/api/v1/genres/:id/copy", async (c) => {
  2000. const genreId = c.req.param("id");
  2001. if (/[/\\\0]/.test(genreId) || genreId.includes("..")) {
  2002. throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${genreId}"`);
  2003. }
  2004. try {
  2005. const { getBuiltinGenresDir } = await import("@actalk/inkos-core");
  2006. const { mkdir: mkdirFs, copyFile } = await import("node:fs/promises");
  2007. const builtinDir = getBuiltinGenresDir();
  2008. const projectGenresDir = join(root, "genres");
  2009. await mkdirFs(projectGenresDir, { recursive: true });
  2010. await copyFile(join(builtinDir, `${genreId}.md`), join(projectGenresDir, `${genreId}.md`));
  2011. return c.json({ ok: true, path: `genres/${genreId}.md` });
  2012. } catch (e) {
  2013. return c.json({ error: String(e) }, 500);
  2014. }
  2015. });
  2016. // --- Model overrides ---
  2017. app.get("/api/v1/project/model-overrides", async (c) => {
  2018. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
  2019. return c.json({ overrides: raw.modelOverrides ?? {} });
  2020. });
  2021. app.put("/api/v1/project/model-overrides", async (c) => {
  2022. const { overrides } = await c.req.json<{ overrides: Record<string, unknown> }>();
  2023. const configPath = join(root, "inkos.json");
  2024. const raw = JSON.parse(await readFile(configPath, "utf-8"));
  2025. raw.modelOverrides = overrides;
  2026. const { writeFile: writeFileFs } = await import("node:fs/promises");
  2027. await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
  2028. return c.json({ ok: true });
  2029. });
  2030. // --- Notify channels ---
  2031. app.get("/api/v1/project/notify", async (c) => {
  2032. const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
  2033. return c.json({ channels: raw.notify ?? [] });
  2034. });
  2035. app.put("/api/v1/project/notify", async (c) => {
  2036. const { channels } = await c.req.json<{ channels: unknown[] }>();
  2037. const configPath = join(root, "inkos.json");
  2038. const raw = JSON.parse(await readFile(configPath, "utf-8"));
  2039. raw.notify = channels;
  2040. const { writeFile: writeFileFs } = await import("node:fs/promises");
  2041. await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
  2042. return c.json({ ok: true });
  2043. });
  2044. // --- AIGC Detection ---
  2045. app.post("/api/v1/books/:id/detect/:chapter", async (c) => {
  2046. const id = c.req.param("id");
  2047. const chapterNum = parseInt(c.req.param("chapter"), 10);
  2048. const bookDir = state.bookDir(id);
  2049. try {
  2050. const chaptersDir = join(bookDir, "chapters");
  2051. const files = await readdir(chaptersDir);
  2052. const paddedNum = String(chapterNum).padStart(4, "0");
  2053. const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  2054. if (!match) return c.json({ error: "Chapter not found" }, 404);
  2055. const content = await readFile(join(chaptersDir, match), "utf-8");
  2056. const { analyzeAITells } = await import("@actalk/inkos-core");
  2057. const result = analyzeAITells(content);
  2058. return c.json({ chapterNumber: chapterNum, ...result });
  2059. } catch (e) {
  2060. return c.json({ error: String(e) }, 500);
  2061. }
  2062. });
  2063. // --- Truth file edit ---
  2064. app.put("/api/v1/books/:id/truth/:file{.+}", async (c) => {
  2065. const id = c.req.param("id");
  2066. const file = c.req.param("file");
  2067. const bookDir = state.bookDir(id);
  2068. const resolved = resolveTruthFilePath(bookDir, file);
  2069. if (!resolved) {
  2070. return c.json({ error: "Invalid truth file" }, 400);
  2071. }
  2072. // Legacy pointer shims are read-only in new-layout books: writing
  2073. // story_bible.md or book_rules.md does nothing at runtime (the pipeline
  2074. // reads outline/ instead). For pre-Phase-5 books these ARE authoritative.
  2075. if (LEGACY_SHIM_FILES.has(file)) {
  2076. const { isNewLayoutBook } = await import("@actalk/inkos-core");
  2077. if (await isNewLayoutBook(bookDir)) {
  2078. return c.json(
  2079. { error: "Legacy compat shim; edit outline/story_frame.md instead" },
  2080. 400,
  2081. );
  2082. }
  2083. }
  2084. const { content } = await c.req.json<{ content: string }>();
  2085. const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
  2086. const { dirname: dirnameFs } = await import("node:path");
  2087. await mkdirFs(dirnameFs(resolved), { recursive: true });
  2088. await writeFileFs(resolved, content, "utf-8");
  2089. return c.json({ ok: true });
  2090. });
  2091. // =============================================
  2092. // NEW ENDPOINTS — CLI parity
  2093. // =============================================
  2094. // --- Book Delete ---
  2095. app.delete("/api/v1/books/:id", async (c) => {
  2096. const id = c.req.param("id");
  2097. const bookDir = state.bookDir(id);
  2098. try {
  2099. const { rm } = await import("node:fs/promises");
  2100. await rm(bookDir, { recursive: true, force: true });
  2101. broadcast("book:deleted", { bookId: id });
  2102. return c.json({ ok: true, bookId: id });
  2103. } catch (e) {
  2104. return c.json({ error: String(e) }, 500);
  2105. }
  2106. });
  2107. // --- Book Update ---
  2108. app.put("/api/v1/books/:id", async (c) => {
  2109. const id = c.req.param("id");
  2110. const updates = await c.req.json<{
  2111. chapterWordCount?: number;
  2112. targetChapters?: number;
  2113. status?: string;
  2114. language?: string;
  2115. }>();
  2116. try {
  2117. const book = await state.loadBookConfig(id);
  2118. const updated = {
  2119. ...book,
  2120. ...(updates.chapterWordCount !== undefined ? { chapterWordCount: Number(updates.chapterWordCount) } : {}),
  2121. ...(updates.targetChapters !== undefined ? { targetChapters: Number(updates.targetChapters) } : {}),
  2122. ...(updates.status !== undefined ? { status: updates.status as typeof book.status } : {}),
  2123. ...(updates.language !== undefined ? { language: updates.language as "zh" | "en" } : {}),
  2124. updatedAt: new Date().toISOString(),
  2125. };
  2126. await state.saveBookConfig(id, updated);
  2127. return c.json({ ok: true, book: updated });
  2128. } catch (e) {
  2129. return c.json({ error: String(e) }, 500);
  2130. }
  2131. });
  2132. // --- Write Rewrite (specific chapter) ---
  2133. app.post("/api/v1/books/:id/rewrite/:chapter", async (c) => {
  2134. const id = c.req.param("id");
  2135. const chapterNum = parseInt(c.req.param("chapter"), 10);
  2136. const body: { brief?: string } = await c.req
  2137. .json<{ brief?: string }>()
  2138. .catch(() => ({}));
  2139. broadcast("rewrite:start", { bookId: id, chapter: chapterNum });
  2140. try {
  2141. const rollbackTarget = chapterNum - 1;
  2142. const discarded = await state.rollbackToChapter(id, rollbackTarget);
  2143. const pipeline = new PipelineRunner(await buildPipelineConfig({
  2144. externalContext: body.brief,
  2145. }));
  2146. pipeline.writeNextChapter(id).then(
  2147. (result) => broadcast("rewrite:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount }),
  2148. (e) => broadcast("rewrite:error", { bookId: id, error: e instanceof Error ? e.message : String(e) }),
  2149. );
  2150. return c.json({ status: "rewriting", bookId: id, chapter: chapterNum, rolledBackTo: rollbackTarget, discarded });
  2151. } catch (e) {
  2152. broadcast("rewrite:error", { bookId: id, error: String(e) });
  2153. return c.json({ error: String(e) }, 500);
  2154. }
  2155. });
  2156. app.post("/api/v1/books/:id/resync/:chapter", async (c) => {
  2157. const id = c.req.param("id");
  2158. const chapterNum = parseInt(c.req.param("chapter"), 10);
  2159. const body: { brief?: string } = await c.req
  2160. .json<{ brief?: string }>()
  2161. .catch(() => ({}));
  2162. try {
  2163. const pipeline = new PipelineRunner(await buildPipelineConfig({
  2164. externalContext: body.brief,
  2165. }));
  2166. const result = await pipeline.resyncChapterArtifacts(id, chapterNum);
  2167. return c.json(result);
  2168. } catch (e) {
  2169. return c.json({ error: String(e) }, 500);
  2170. }
  2171. });
  2172. // --- Detect All chapters ---
  2173. app.post("/api/v1/books/:id/detect-all", async (c) => {
  2174. const id = c.req.param("id");
  2175. const bookDir = state.bookDir(id);
  2176. try {
  2177. const chaptersDir = join(bookDir, "chapters");
  2178. const files = await readdir(chaptersDir);
  2179. const mdFiles = files.filter((f) => f.endsWith(".md") && /^\d{4}/.test(f)).sort();
  2180. const { analyzeAITells } = await import("@actalk/inkos-core");
  2181. const results = await Promise.all(
  2182. mdFiles.map(async (f) => {
  2183. const num = parseInt(f.slice(0, 4), 10);
  2184. const content = await readFile(join(chaptersDir, f), "utf-8");
  2185. const result = analyzeAITells(content);
  2186. return { chapterNumber: num, filename: f, ...result };
  2187. }),
  2188. );
  2189. return c.json({ bookId: id, results });
  2190. } catch (e) {
  2191. return c.json({ error: String(e) }, 500);
  2192. }
  2193. });
  2194. // --- Detect Stats ---
  2195. app.get("/api/v1/books/:id/detect/stats", async (c) => {
  2196. const id = c.req.param("id");
  2197. try {
  2198. const { loadDetectionHistory, analyzeDetectionInsights } = await import("@actalk/inkos-core");
  2199. const bookDir = state.bookDir(id);
  2200. const history = await loadDetectionHistory(bookDir);
  2201. const insights = analyzeDetectionInsights(history);
  2202. return c.json(insights);
  2203. } catch (e) {
  2204. return c.json({ error: String(e) }, 500);
  2205. }
  2206. });
  2207. // --- Genre Create ---
  2208. app.post("/api/v1/genres/create", async (c) => {
  2209. const body = await c.req.json<{
  2210. id: string; name: string; language?: string;
  2211. chapterTypes?: string[]; fatigueWords?: string[];
  2212. numericalSystem?: boolean; powerScaling?: boolean; eraResearch?: boolean;
  2213. pacingRule?: string; satisfactionTypes?: string[]; auditDimensions?: number[];
  2214. body?: string;
  2215. }>();
  2216. if (!body.id || !body.name) {
  2217. return c.json({ error: "id and name are required" }, 400);
  2218. }
  2219. if (/[/\\\0]/.test(body.id) || body.id.includes("..")) {
  2220. throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${body.id}"`);
  2221. }
  2222. const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
  2223. const genresDir = join(root, "genres");
  2224. await mkdirFs(genresDir, { recursive: true });
  2225. const frontmatter = [
  2226. "---",
  2227. `name: ${body.name}`,
  2228. `id: ${body.id}`,
  2229. `language: ${body.language ?? "zh"}`,
  2230. `chapterTypes: ${JSON.stringify(body.chapterTypes ?? [])}`,
  2231. `fatigueWords: ${JSON.stringify(body.fatigueWords ?? [])}`,
  2232. `numericalSystem: ${body.numericalSystem ?? false}`,
  2233. `powerScaling: ${body.powerScaling ?? false}`,
  2234. `eraResearch: ${body.eraResearch ?? false}`,
  2235. `pacingRule: "${body.pacingRule ?? ""}"`,
  2236. `satisfactionTypes: ${JSON.stringify(body.satisfactionTypes ?? [])}`,
  2237. `auditDimensions: ${JSON.stringify(body.auditDimensions ?? [])}`,
  2238. "---",
  2239. "",
  2240. body.body ?? "",
  2241. ].join("\n");
  2242. await writeFileFs(join(genresDir, `${body.id}.md`), frontmatter, "utf-8");
  2243. return c.json({ ok: true, id: body.id });
  2244. });
  2245. // --- Genre Edit ---
  2246. app.put("/api/v1/genres/:id", async (c) => {
  2247. const genreId = c.req.param("id");
  2248. if (/[/\\\0]/.test(genreId) || genreId.includes("..")) {
  2249. throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${genreId}"`);
  2250. }
  2251. const body = await c.req.json<{ profile: Record<string, unknown>; body: string }>();
  2252. const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
  2253. const genresDir = join(root, "genres");
  2254. await mkdirFs(genresDir, { recursive: true });
  2255. const p = body.profile;
  2256. const frontmatter = [
  2257. "---",
  2258. `name: ${p.name ?? genreId}`,
  2259. `id: ${p.id ?? genreId}`,
  2260. `language: ${p.language ?? "zh"}`,
  2261. `chapterTypes: ${JSON.stringify(p.chapterTypes ?? [])}`,
  2262. `fatigueWords: ${JSON.stringify(p.fatigueWords ?? [])}`,
  2263. `numericalSystem: ${p.numericalSystem ?? false}`,
  2264. `powerScaling: ${p.powerScaling ?? false}`,
  2265. `eraResearch: ${p.eraResearch ?? false}`,
  2266. `pacingRule: "${p.pacingRule ?? ""}"`,
  2267. `satisfactionTypes: ${JSON.stringify(p.satisfactionTypes ?? [])}`,
  2268. `auditDimensions: ${JSON.stringify(p.auditDimensions ?? [])}`,
  2269. "---",
  2270. "",
  2271. body.body ?? "",
  2272. ].join("\n");
  2273. await writeFileFs(join(genresDir, `${genreId}.md`), frontmatter, "utf-8");
  2274. return c.json({ ok: true, id: genreId });
  2275. });
  2276. // --- Genre Delete (project-level only) ---
  2277. app.delete("/api/v1/genres/:id", async (c) => {
  2278. const genreId = c.req.param("id");
  2279. if (/[/\\\0]/.test(genreId) || genreId.includes("..")) {
  2280. throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${genreId}"`);
  2281. }
  2282. const filePath = join(root, "genres", `${genreId}.md`);
  2283. try {
  2284. const { rm } = await import("node:fs/promises");
  2285. await rm(filePath);
  2286. return c.json({ ok: true, id: genreId });
  2287. } catch (e) {
  2288. return c.json({ error: `Genre "${genreId}" not found in project` }, 404);
  2289. }
  2290. });
  2291. // --- Style Analyze ---
  2292. app.post("/api/v1/style/analyze", async (c) => {
  2293. const { text, sourceName } = await c.req.json<{ text: string; sourceName: string }>();
  2294. if (!text?.trim()) return c.json({ error: "text is required" }, 400);
  2295. try {
  2296. const { analyzeStyle } = await import("@actalk/inkos-core");
  2297. const profile = analyzeStyle(text, sourceName ?? "unknown");
  2298. return c.json(profile);
  2299. } catch (e) {
  2300. return c.json({ error: String(e) }, 500);
  2301. }
  2302. });
  2303. // --- Style Import to Book ---
  2304. app.post("/api/v1/books/:id/style/import", async (c) => {
  2305. const id = c.req.param("id");
  2306. const { text, sourceName } = await c.req.json<{ text: string; sourceName: string }>();
  2307. broadcast("style:start", { bookId: id });
  2308. try {
  2309. const pipeline = new PipelineRunner(await buildPipelineConfig());
  2310. const result = await pipeline.generateStyleGuide(id, text, sourceName ?? "unknown");
  2311. broadcast("style:complete", { bookId: id });
  2312. return c.json({ ok: true, result });
  2313. } catch (e) {
  2314. broadcast("style:error", { bookId: id, error: String(e) });
  2315. return c.json({ error: String(e) }, 500);
  2316. }
  2317. });
  2318. // --- Import Chapters ---
  2319. app.post("/api/v1/books/:id/import/chapters", async (c) => {
  2320. const id = c.req.param("id");
  2321. const { text, splitRegex } = await c.req.json<{ text: string; splitRegex?: string }>();
  2322. if (!text?.trim()) return c.json({ error: "text is required" }, 400);
  2323. broadcast("import:start", { bookId: id, type: "chapters" });
  2324. try {
  2325. const { splitChapters } = await import("@actalk/inkos-core");
  2326. const chapters = [...splitChapters(text, splitRegex)];
  2327. const pipeline = new PipelineRunner(await buildPipelineConfig());
  2328. const result = await pipeline.importChapters({ bookId: id, chapters });
  2329. broadcast("import:complete", { bookId: id, type: "chapters", count: result.importedCount });
  2330. return c.json(result);
  2331. } catch (e) {
  2332. broadcast("import:error", { bookId: id, error: String(e) });
  2333. return c.json({ error: String(e) }, 500);
  2334. }
  2335. });
  2336. // --- Import Canon ---
  2337. app.post("/api/v1/books/:id/import/canon", async (c) => {
  2338. const id = c.req.param("id");
  2339. const { fromBookId } = await c.req.json<{ fromBookId: string }>();
  2340. if (!fromBookId) return c.json({ error: "fromBookId is required" }, 400);
  2341. broadcast("import:start", { bookId: id, type: "canon" });
  2342. try {
  2343. const pipeline = new PipelineRunner(await buildPipelineConfig());
  2344. await pipeline.importCanon(id, fromBookId);
  2345. broadcast("import:complete", { bookId: id, type: "canon" });
  2346. return c.json({ ok: true });
  2347. } catch (e) {
  2348. broadcast("import:error", { bookId: id, error: String(e) });
  2349. return c.json({ error: String(e) }, 500);
  2350. }
  2351. });
  2352. // --- Fanfic Init ---
  2353. app.post("/api/v1/fanfic/init", async (c) => {
  2354. const body = await c.req.json<{
  2355. title: string; sourceText: string; sourceName?: string;
  2356. mode?: string; genre?: string; platform?: string;
  2357. targetChapters?: number; chapterWordCount?: number; language?: string;
  2358. }>();
  2359. if (!body.title || !body.sourceText) {
  2360. return c.json({ error: "title and sourceText are required" }, 400);
  2361. }
  2362. const now = new Date().toISOString();
  2363. const bookId = body.title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, "-").replace(/-+/g, "-").slice(0, 30);
  2364. const bookConfig = {
  2365. id: bookId,
  2366. title: body.title,
  2367. platform: (body.platform ?? "other") as "other",
  2368. genre: (body.genre ?? "other") as "xuanhuan",
  2369. status: "outlining" as const,
  2370. targetChapters: body.targetChapters ?? 100,
  2371. chapterWordCount: body.chapterWordCount ?? 3000,
  2372. fanficMode: (body.mode ?? "canon") as "canon",
  2373. ...(body.language ? { language: body.language as "zh" | "en" } : {}),
  2374. createdAt: now,
  2375. updatedAt: now,
  2376. };
  2377. broadcast("fanfic:start", { bookId, title: body.title });
  2378. try {
  2379. const pipeline = new PipelineRunner(await buildPipelineConfig());
  2380. await pipeline.initFanficBook(bookConfig, body.sourceText, body.sourceName ?? "source", (body.mode ?? "canon") as "canon");
  2381. broadcast("fanfic:complete", { bookId });
  2382. return c.json({ ok: true, bookId });
  2383. } catch (e) {
  2384. broadcast("fanfic:error", { bookId, error: String(e) });
  2385. return c.json({ error: String(e) }, 500);
  2386. }
  2387. });
  2388. // --- Fanfic Show (read canon) ---
  2389. app.get("/api/v1/books/:id/fanfic", async (c) => {
  2390. const id = c.req.param("id");
  2391. const bookDir = state.bookDir(id);
  2392. try {
  2393. const content = await readFile(join(bookDir, "story", "fanfic_canon.md"), "utf-8");
  2394. return c.json({ bookId: id, content });
  2395. } catch {
  2396. return c.json({ bookId: id, content: null });
  2397. }
  2398. });
  2399. // --- Fanfic Refresh ---
  2400. app.post("/api/v1/books/:id/fanfic/refresh", async (c) => {
  2401. const id = c.req.param("id");
  2402. const { sourceText, sourceName } = await c.req.json<{ sourceText: string; sourceName?: string }>();
  2403. if (!sourceText?.trim()) return c.json({ error: "sourceText is required" }, 400);
  2404. broadcast("fanfic:refresh:start", { bookId: id });
  2405. try {
  2406. const book = await state.loadBookConfig(id);
  2407. const pipeline = new PipelineRunner(await buildPipelineConfig());
  2408. await pipeline.importFanficCanon(id, sourceText, sourceName ?? "source", (book.fanficMode ?? "canon") as "canon");
  2409. broadcast("fanfic:refresh:complete", { bookId: id });
  2410. return c.json({ ok: true });
  2411. } catch (e) {
  2412. broadcast("fanfic:refresh:error", { bookId: id, error: String(e) });
  2413. return c.json({ error: String(e) }, 500);
  2414. }
  2415. });
  2416. // --- Radar Scan ---
  2417. app.post("/api/v1/radar/scan", async (c) => {
  2418. broadcast("radar:start", {});
  2419. try {
  2420. const pipeline = new PipelineRunner(await buildPipelineConfig());
  2421. const result = await pipeline.runRadar();
  2422. broadcast("radar:complete", { result });
  2423. return c.json(result);
  2424. } catch (e) {
  2425. broadcast("radar:error", { error: String(e) });
  2426. return c.json({ error: String(e) }, 500);
  2427. }
  2428. });
  2429. // --- Doctor (environment health check) ---
  2430. app.get("/api/v1/doctor", async (c) => {
  2431. const { existsSync } = await import("node:fs");
  2432. const { GLOBAL_ENV_PATH } = await import("@actalk/inkos-core");
  2433. const checks = {
  2434. inkosJson: existsSync(join(root, "inkos.json")),
  2435. projectEnv: existsSync(join(root, ".env")),
  2436. globalEnv: existsSync(GLOBAL_ENV_PATH),
  2437. booksDir: existsSync(join(root, "books")),
  2438. llmConnected: false,
  2439. bookCount: 0,
  2440. };
  2441. try {
  2442. const books = await state.listBooks();
  2443. checks.bookCount = books.length;
  2444. } catch { /* ignore */ }
  2445. try {
  2446. const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
  2447. const service = currentConfig.llm.service ?? currentConfig.llm.provider;
  2448. const probe = await probeServiceCapabilities({
  2449. root,
  2450. service,
  2451. apiKey: currentConfig.llm.apiKey,
  2452. baseUrl: currentConfig.llm.baseUrl,
  2453. preferredApiFormat: currentConfig.llm.apiFormat,
  2454. preferredStream: currentConfig.llm.stream,
  2455. preferredModel: currentConfig.llm.model,
  2456. });
  2457. checks.llmConnected = probe.ok;
  2458. } catch { /* ignore */ }
  2459. return c.json(checks);
  2460. });
  2461. return app;
  2462. }
  2463. // --- Standalone runner ---
  2464. export async function startStudioServer(
  2465. root: string,
  2466. port = 4567,
  2467. options?: { readonly staticDir?: string },
  2468. ): Promise<void> {
  2469. const config = await loadProjectConfig(root, { consumer: "studio", requireApiKey: false });
  2470. const app = createStudioServer(config, root);
  2471. // Serve frontend static files — single process for API + frontend
  2472. if (options?.staticDir) {
  2473. const { readFile: readFileFs } = await import("node:fs/promises");
  2474. const { join: joinPath } = await import("node:path");
  2475. const { existsSync } = await import("node:fs");
  2476. // Serve static assets (js, css, etc.)
  2477. app.get("/assets/*", async (c) => {
  2478. const filePath = joinPath(options.staticDir!, c.req.path);
  2479. try {
  2480. const content = await readFileFs(filePath);
  2481. const ext = filePath.split(".").pop() ?? "";
  2482. const contentTypes: Record<string, string> = {
  2483. js: "application/javascript",
  2484. css: "text/css",
  2485. svg: "image/svg+xml",
  2486. png: "image/png",
  2487. ico: "image/x-icon",
  2488. json: "application/json",
  2489. };
  2490. return new Response(content, {
  2491. headers: { "Content-Type": contentTypes[ext] ?? "application/octet-stream" },
  2492. });
  2493. } catch {
  2494. return c.notFound();
  2495. }
  2496. });
  2497. // SPA fallback — serve index.html for all non-API routes
  2498. const indexPath = joinPath(options.staticDir!, "index.html");
  2499. if (existsSync(indexPath)) {
  2500. const indexHtml = await readFileFs(indexPath, "utf-8");
  2501. app.get("*", (c) => {
  2502. if (c.req.path.startsWith("/api/v1/")) return c.notFound();
  2503. return c.html(indexHtml);
  2504. });
  2505. }
  2506. }
  2507. console.log(`InkOS Studio running on http://localhost:${port}`);
  2508. serve({ fetch: app.fetch, port });
  2509. }