server.ts 90 KB

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