server.ts 110 KB

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