| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183 |
- import { Hono } from "hono";
- import { cors } from "hono/cors";
- import { streamSSE } from "hono/streaming";
- import { serve } from "@hono/node-server";
- import {
- StateManager,
- PipelineRunner,
- createLLMClient,
- createLogger,
- createInteractionToolsFromDeps,
- computeAnalytics,
- loadProjectConfig,
- loadProjectSession,
- processProjectInteractionRequest,
- resolveSessionActiveBook,
- findOrCreateBookSession,
- listBookSessions,
- loadBookSession,
- persistBookSession,
- appendBookSessionMessage,
- runAgentSession,
- buildAgentSystemPrompt,
- resolveServicePreset,
- resolveServiceModel,
- loadSecrets,
- saveSecrets,
- getServiceApiKey,
- listModelsForService,
- chatCompletion,
- GLOBAL_ENV_PATH,
- type ResolvedModel,
- type PipelineConfig,
- type ProjectConfig,
- type LogSink,
- type LogEntry,
- type BookSession,
- } from "@actalk/inkos-core";
- import { access, readFile, readdir, writeFile } from "node:fs/promises";
- import { join } from "node:path";
- import { isSafeBookId } from "./safety.js";
- import { ApiError } from "./errors.js";
- import { buildStudioBookConfig } from "./book-create.js";
- // -- Pipeline stage definitions per agent type --
- const PIPELINE_STAGES: Record<string, string[]> = {
- writer: [
- "准备章节输入", "撰写章节草稿", "落盘最终章节",
- "生成最终真相文件", "校验真相文件变更", "同步记忆索引",
- "更新章节索引与快照",
- ],
- architect: [
- "生成基础设定", "保存书籍配置", "写入基础设定文件",
- "初始化控制文档", "创建初始快照",
- ],
- reviser: [
- "加载修订上下文", "修订章节", "落盘修订结果",
- "更新索引与快照",
- ],
- auditor: ["审计章节"],
- };
- const AGENT_LABELS: Record<string, string> = {
- architect: "建书", writer: "写作", auditor: "审计",
- reviser: "修订", exporter: "导出",
- };
- const TOOL_LABELS: Record<string, string> = {
- read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
- };
- function resolveToolLabel(tool: string, agent?: string): string {
- if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
- return TOOL_LABELS[tool] ?? tool;
- }
- function summarizeResult(result: unknown): string {
- if (typeof result === "string") return result.slice(0, 200);
- if (result && typeof result === "object") {
- const r = result as Record<string, unknown>;
- if (typeof r.content === "string") return r.content.slice(0, 200);
- if (typeof r.text === "string") return r.text.slice(0, 200);
- }
- return String(result).slice(0, 200);
- }
- function extractToolError(result: unknown): string {
- if (typeof result === "string") return result.slice(0, 500);
- if (result && typeof result === "object") {
- const r = result as Record<string, unknown>;
- if (typeof r.content === "string") return r.content.slice(0, 500);
- if (r.content && Array.isArray(r.content)) {
- const textPart = r.content.find((c: any) => c.type === "text");
- if (textPart) return (textPart as any).text?.slice(0, 500) ?? "";
- }
- }
- return String(result).slice(0, 500);
- }
- interface CollectedToolExec {
- id: string;
- tool: string;
- agent?: string;
- label: string;
- status: "running" | "completed" | "error";
- args?: Record<string, unknown>;
- result?: string;
- error?: string;
- stages?: Array<{ label: string; status: "pending" | "completed" }>;
- startedAt: number;
- completedAt?: number;
- }
- // --- Event bus for SSE ---
- type EventHandler = (event: string, data: unknown) => void;
- const subscribers = new Set<EventHandler>();
- const bookCreateStatus = new Map<string, { status: "creating" | "error"; error?: string }>();
- interface ServiceConfigEntry {
- service: string;
- name?: string;
- baseUrl?: string;
- temperature?: number;
- maxTokens?: number;
- apiFormat?: "chat" | "responses";
- stream?: boolean;
- }
- type LLMConfigSource = "env" | "studio";
- interface EnvConfigSummary {
- detected: boolean;
- provider: string | null;
- baseUrl: string | null;
- model: string | null;
- hasApiKey: boolean;
- }
- interface EnvConfigStatus {
- project: EnvConfigSummary;
- global: EnvConfigSummary;
- effectiveSource: "project" | "global" | null;
- }
- function broadcast(event: string, data: unknown): void {
- for (const handler of subscribers) {
- handler(event, data);
- }
- }
- function isCustomServiceId(serviceId: string): boolean {
- return serviceId === "custom" || serviceId.startsWith("custom:");
- }
- function serviceConfigKey(entry: ServiceConfigEntry): string {
- return entry.service === "custom" ? `custom:${entry.name ?? "Custom"}` : entry.service;
- }
- function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>): ServiceConfigEntry {
- if (serviceId.startsWith("custom:")) {
- return {
- service: "custom",
- name: decodeURIComponent(serviceId.slice("custom:".length)),
- ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
- ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
- ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
- ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
- ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
- };
- }
- if (serviceId === "custom") {
- return {
- service: "custom",
- ...(typeof value.name === "string" && value.name.length > 0 ? { name: value.name } : {}),
- ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
- ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
- ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
- ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
- ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
- };
- }
- return {
- service: serviceId,
- ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
- ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
- ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
- ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
- };
- }
- function normalizeConfigSource(value: unknown): LLMConfigSource {
- return value === "studio" ? "studio" : "env";
- }
- function normalizeServiceConfig(raw: unknown): ServiceConfigEntry[] {
- if (Array.isArray(raw)) {
- return raw
- .filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
- .map((entry) => ({
- service: typeof entry.service === "string" && entry.service.length > 0 ? entry.service : "custom",
- ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
- ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
- ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
- ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
- ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
- ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
- }));
- }
- if (raw && typeof raw === "object") {
- return Object.entries(raw as Record<string, unknown>)
- .filter(([, value]) => value && typeof value === "object")
- .map(([serviceId, value]) => normalizeServiceEntry(serviceId, value as Record<string, unknown>));
- }
- return [];
- }
- function mergeServiceConfig(existing: ServiceConfigEntry[], updates: ServiceConfigEntry[]): ServiceConfigEntry[] {
- const merged = new Map(existing.map((entry) => [serviceConfigKey(entry), entry]));
- for (const update of updates) {
- merged.set(serviceConfigKey(update), update);
- }
- return [...merged.values()];
- }
- async function loadRawConfig(root: string): Promise<Record<string, unknown>> {
- const configPath = join(root, "inkos.json");
- const raw = await readFile(configPath, "utf-8");
- return JSON.parse(raw) as Record<string, unknown>;
- }
- async function saveRawConfig(root: string, config: Record<string, unknown>): Promise<void> {
- await writeFile(join(root, "inkos.json"), JSON.stringify(config, null, 2), "utf-8");
- }
- async function readEnvConfigSummary(path: string): Promise<EnvConfigSummary> {
- try {
- const raw = await readFile(path, "utf-8");
- const values = new Map<string, string>();
- for (const line of raw.split(/\r?\n/)) {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith("#")) continue;
- const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
- if (!match) continue;
- const [, key, value] = match;
- values.set(key, value.trim());
- }
- const provider = values.get("INKOS_LLM_PROVIDER") ?? null;
- const baseUrl = values.get("INKOS_LLM_BASE_URL") ?? null;
- const model = values.get("INKOS_LLM_MODEL") ?? null;
- const apiKey = values.get("INKOS_LLM_API_KEY") ?? "";
- const detected = Boolean(provider || baseUrl || model || apiKey);
- return {
- detected,
- provider,
- baseUrl,
- model,
- hasApiKey: apiKey.length > 0,
- };
- } catch {
- return {
- detected: false,
- provider: null,
- baseUrl: null,
- model: null,
- hasApiKey: false,
- };
- }
- }
- async function readEnvConfigStatus(root: string): Promise<EnvConfigStatus> {
- const project = await readEnvConfigSummary(join(root, ".env"));
- const global = await readEnvConfigSummary(GLOBAL_ENV_PATH);
- return {
- project,
- global,
- effectiveSource: project.detected ? "project" : global.detected ? "global" : null,
- };
- }
- async function resolveConfiguredServiceBaseUrl(root: string, serviceId: string, inlineBaseUrl?: string): Promise<string | undefined> {
- if (inlineBaseUrl?.trim()) return inlineBaseUrl.trim();
- if (!isCustomServiceId(serviceId)) {
- return resolveServicePreset(serviceId)?.baseUrl;
- }
- try {
- const config = await loadRawConfig(root);
- const services = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services);
- const matched = services.find((entry) => serviceConfigKey(entry) === serviceId);
- return matched?.baseUrl;
- } catch {
- return undefined;
- }
- }
- async function resolveConfiguredServiceEntry(root: string, serviceId: string): Promise<ServiceConfigEntry | undefined> {
- try {
- const config = await loadRawConfig(root);
- const services = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services);
- return services.find((entry) => serviceConfigKey(entry) === serviceId);
- } catch {
- return undefined;
- }
- }
- // --- Server factory ---
- export function createStudioServer(initialConfig: ProjectConfig, root: string) {
- const app = new Hono();
- const state = new StateManager(root);
- let cachedConfig = initialConfig;
- app.use("/*", cors());
- // Structured error handler — ApiError returns typed JSON, others return 500
- app.onError((error, c) => {
- if (error instanceof ApiError) {
- return c.json({ error: { code: error.code, message: error.message } }, error.status as 400);
- }
- return c.json(
- { error: { code: "INTERNAL_ERROR", message: "Unexpected server error." } },
- 500,
- );
- });
- // BookId validation middleware — blocks path traversal on all book routes
- app.use("/api/v1/books/:id/*", async (c, next) => {
- const bookId = c.req.param("id");
- if (!isSafeBookId(bookId)) {
- throw new ApiError(400, "INVALID_BOOK_ID", `Invalid book ID: "${bookId}"`);
- }
- await next();
- });
- app.use("/api/v1/books/:id", async (c, next) => {
- const bookId = c.req.param("id");
- if (!isSafeBookId(bookId)) {
- throw new ApiError(400, "INVALID_BOOK_ID", `Invalid book ID: "${bookId}"`);
- }
- await next();
- });
- // Logger sink that broadcasts to SSE
- const sseSink: LogSink = {
- write(entry: LogEntry): void {
- broadcast("log", { level: entry.level, tag: entry.tag, message: entry.message });
- },
- };
- // Logger sink that prints to server terminal
- const consoleSink: LogSink = {
- write(entry: LogEntry): void {
- const prefix = `[${entry.tag}]`;
- if (entry.level === "warn") console.warn(prefix, entry.message);
- else if (entry.level === "error") console.error(prefix, entry.message);
- else console.log(prefix, entry.message);
- },
- };
- async function loadCurrentProjectConfig(
- options?: { readonly requireApiKey?: boolean },
- ): Promise<ProjectConfig> {
- const freshConfig = await loadProjectConfig(root, options);
- cachedConfig = freshConfig;
- return freshConfig;
- }
- async function buildPipelineConfig(
- overrides?: Partial<Pick<PipelineConfig, "externalContext" | "client" | "model">> & {
- readonly currentConfig?: ProjectConfig;
- },
- ): Promise<PipelineConfig> {
- const currentConfig = overrides?.currentConfig ?? await loadCurrentProjectConfig();
- const logger = createLogger({ tag: "studio", sinks: [sseSink, consoleSink] });
- return {
- client: overrides?.client ?? createLLMClient(currentConfig.llm),
- model: overrides?.model ?? currentConfig.llm.model,
- projectRoot: root,
- defaultLLMConfig: currentConfig.llm,
- modelOverrides: currentConfig.modelOverrides,
- notifyChannels: currentConfig.notify,
- logger,
- onStreamProgress: (progress) => {
- broadcast("llm:progress", {
- status: progress.status,
- elapsedMs: progress.elapsedMs,
- totalChars: progress.totalChars,
- chineseChars: progress.chineseChars,
- });
- },
- externalContext: overrides?.externalContext,
- };
- }
- // --- Books ---
- app.get("/api/v1/books", async (c) => {
- const bookIds = await state.listBooks();
- const books = await Promise.all(
- bookIds.map(async (id) => {
- const book = await state.loadBookConfig(id);
- const nextChapter = await state.getNextChapterNumber(id);
- return { ...book, chaptersWritten: nextChapter - 1 };
- }),
- );
- return c.json({ books });
- });
- app.get("/api/v1/books/:id", async (c) => {
- const id = c.req.param("id");
- try {
- const book = await state.loadBookConfig(id);
- const chapters = await state.loadChapterIndex(id);
- const nextChapter = await state.getNextChapterNumber(id);
- return c.json({ book, chapters, nextChapter });
- } catch {
- return c.json({ error: `Book "${id}" not found` }, 404);
- }
- });
- // --- Genres ---
- app.get("/api/v1/genres", async (c) => {
- const { listAvailableGenres, readGenreProfile } = await import("@actalk/inkos-core");
- const rawGenres = await listAvailableGenres(root);
- const genres = await Promise.all(
- rawGenres.map(async (g) => {
- try {
- const { profile } = await readGenreProfile(root, g.id);
- return { ...g, language: profile.language ?? "zh" };
- } catch {
- return { ...g, language: "zh" };
- }
- }),
- );
- return c.json({ genres });
- });
- // --- Book Create ---
- app.post("/api/v1/books/create", async (c) => {
- const body = await c.req.json<{
- title: string;
- genre: string;
- language?: string;
- platform?: string;
- chapterWordCount?: number;
- targetChapters?: number;
- }>();
- const now = new Date().toISOString();
- const bookConfig = buildStudioBookConfig(body, now);
- const bookId = bookConfig.id;
- const bookDir = state.bookDir(bookId);
- try {
- await access(join(bookDir, "book.json"));
- await access(join(bookDir, "story", "story_bible.md"));
- return c.json({ error: `Book "${bookId}" already exists` }, 409);
- } catch {
- // The target book is not fully initialized yet, so creation can continue.
- }
- broadcast("book:creating", { bookId, title: body.title });
- bookCreateStatus.set(bookId, { status: "creating" });
- const pipeline = new PipelineRunner(await buildPipelineConfig());
- const tools = createInteractionToolsFromDeps(pipeline, state);
- processProjectInteractionRequest({
- projectRoot: root,
- request: {
- intent: "create_book",
- title: body.title,
- genre: body.genre,
- language: body.language === "en" ? "en" : body.language === "zh" ? "zh" : undefined,
- platform: body.platform,
- chapterWordCount: body.chapterWordCount,
- targetChapters: body.targetChapters,
- },
- tools,
- }).then(
- (result: {
- readonly session: { readonly activeBookId?: string };
- readonly details?: Readonly<Record<string, unknown>>;
- }) => {
- const createdBookId = (result.details?.bookId as string | undefined) ?? result.session.activeBookId ?? bookId;
- bookCreateStatus.delete(createdBookId);
- broadcast("book:created", { bookId: createdBookId });
- },
- (e: unknown) => {
- const error = e instanceof Error ? e.message : String(e);
- bookCreateStatus.set(bookId, { status: "error", error });
- broadcast("book:error", { bookId, error });
- },
- );
- return c.json({ status: "creating", bookId });
- });
- app.get("/api/v1/books/:id/create-status", async (c) => {
- const id = c.req.param("id");
- const status = bookCreateStatus.get(id);
- if (!status) {
- return c.json({ status: "missing" }, 404);
- }
- return c.json(status);
- });
- // --- Chapters ---
- app.get("/api/v1/books/:id/chapters/:num", async (c) => {
- const id = c.req.param("id");
- const num = parseInt(c.req.param("num"), 10);
- const bookDir = state.bookDir(id);
- const chaptersDir = join(bookDir, "chapters");
- try {
- const files = await readdir(chaptersDir);
- const paddedNum = String(num).padStart(4, "0");
- const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
- if (!match) return c.json({ error: "Chapter not found" }, 404);
- const content = await readFile(join(chaptersDir, match), "utf-8");
- return c.json({ chapterNumber: num, filename: match, content });
- } catch {
- return c.json({ error: "Chapter not found" }, 404);
- }
- });
- // --- Chapter Save ---
- app.put("/api/v1/books/:id/chapters/:num", async (c) => {
- const id = c.req.param("id");
- const num = parseInt(c.req.param("num"), 10);
- const bookDir = state.bookDir(id);
- const chaptersDir = join(bookDir, "chapters");
- const { content } = await c.req.json<{ content: string }>();
- try {
- const files = await readdir(chaptersDir);
- const paddedNum = String(num).padStart(4, "0");
- const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
- if (!match) return c.json({ error: "Chapter not found" }, 404);
- const { writeFile: writeFileFs } = await import("node:fs/promises");
- await writeFileFs(join(chaptersDir, match), content, "utf-8");
- return c.json({ ok: true, chapterNumber: num });
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Truth files ---
- const TRUTH_FILES = [
- "author_intent.md", "current_focus.md",
- "story_bible.md", "volume_outline.md", "current_state.md",
- "particle_ledger.md", "pending_hooks.md", "chapter_summaries.md",
- "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
- "style_guide.md", "parent_canon.md", "fanfic_canon.md", "book_rules.md",
- ];
- app.get("/api/v1/books/:id/truth/:file", async (c) => {
- const id = c.req.param("id");
- const file = c.req.param("file");
- if (!TRUTH_FILES.includes(file)) {
- return c.json({ error: "Invalid truth file" }, 400);
- }
- const bookDir = state.bookDir(id);
- try {
- const content = await readFile(join(bookDir, "story", file), "utf-8");
- return c.json({ file, content });
- } catch {
- return c.json({ file, content: null });
- }
- });
- // --- Analytics ---
- app.get("/api/v1/books/:id/analytics", async (c) => {
- const id = c.req.param("id");
- try {
- const chapters = await state.loadChapterIndex(id);
- return c.json(computeAnalytics(id, chapters));
- } catch {
- return c.json({ error: `Book "${id}" not found` }, 404);
- }
- });
- // --- Actions ---
- app.post("/api/v1/books/:id/write-next", async (c) => {
- const id = c.req.param("id");
- const body = await c.req.json<{ wordCount?: number }>().catch(() => ({ wordCount: undefined }));
- broadcast("write:start", { bookId: id });
- // Fire and forget — progress/completion/errors pushed via SSE
- const pipeline = new PipelineRunner(await buildPipelineConfig());
- pipeline.writeNextChapter(id, body.wordCount).then(
- (result) => {
- broadcast("write:complete", { bookId: id, chapterNumber: result.chapterNumber, status: result.status, title: result.title, wordCount: result.wordCount });
- },
- (e) => {
- broadcast("write:error", { bookId: id, error: e instanceof Error ? e.message : String(e) });
- },
- );
- return c.json({ status: "writing", bookId: id });
- });
- app.post("/api/v1/books/:id/draft", async (c) => {
- const id = c.req.param("id");
- const body = await c.req.json<{ wordCount?: number; context?: string }>().catch(() => ({ wordCount: undefined, context: undefined }));
- broadcast("draft:start", { bookId: id });
- const pipeline = new PipelineRunner(await buildPipelineConfig());
- pipeline.writeDraft(id, body.context, body.wordCount).then(
- (result) => {
- broadcast("draft:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount });
- },
- (e) => {
- broadcast("draft:error", { bookId: id, error: e instanceof Error ? e.message : String(e) });
- },
- );
- return c.json({ status: "drafting", bookId: id });
- });
- app.post("/api/v1/books/:id/chapters/:num/approve", async (c) => {
- const id = c.req.param("id");
- const num = parseInt(c.req.param("num"), 10);
- try {
- const index = await state.loadChapterIndex(id);
- const updated = index.map((ch) =>
- ch.number === num ? { ...ch, status: "approved" as const } : ch,
- );
- await state.saveChapterIndex(id, updated);
- return c.json({ ok: true, chapterNumber: num, status: "approved" });
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- app.post("/api/v1/books/:id/chapters/:num/reject", async (c) => {
- const id = c.req.param("id");
- const num = parseInt(c.req.param("num"), 10);
- try {
- const index = await state.loadChapterIndex(id);
- const target = index.find((ch) => ch.number === num);
- if (!target) {
- return c.json({ error: `Chapter ${num} not found` }, 404);
- }
- const rollbackTarget = num - 1;
- const discarded = await state.rollbackToChapter(id, rollbackTarget);
- return c.json({
- ok: true,
- chapterNumber: num,
- status: "rejected",
- rolledBackTo: rollbackTarget,
- discarded,
- });
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- SSE ---
- app.get("/api/v1/events", (c) => {
- return streamSSE(c, async (stream) => {
- const handler: EventHandler = (event, data) => {
- stream.writeSSE({ event, data: JSON.stringify(data) });
- };
- subscribers.add(handler);
- // Keep alive
- const keepAlive = setInterval(() => {
- stream.writeSSE({ event: "ping", data: "" });
- }, 30000);
- stream.onAbort(() => {
- subscribers.delete(handler);
- clearInterval(keepAlive);
- });
- // Block until aborted
- await new Promise(() => {});
- });
- });
- // --- Model discovery ---
- app.get("/api/v1/services", async (c) => {
- const secrets = await loadSecrets(root);
- const SERVICE_KEYS = [
- "openai", "anthropic", "deepseek", "moonshot", "minimax",
- "bailian", "zhipu", "siliconflow", "ppio", "openrouter", "ollama",
- ];
- // Fast: only check connection status from secrets, no external API calls
- const services = SERVICE_KEYS.map((key) => {
- const preset = resolveServicePreset(key);
- return {
- service: key,
- label: preset?.label ?? key,
- connected: Boolean(secrets.services[key]?.apiKey),
- };
- });
- // Add custom services from inkos.json
- try {
- const config = await loadRawConfig(root);
- for (const svc of normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services)) {
- if (svc.service === "custom") {
- const secretKey = `custom:${svc.name}`;
- services.push({
- service: secretKey,
- label: svc.name ?? "Custom",
- connected: Boolean(secrets.services[secretKey]?.apiKey),
- });
- }
- }
- } catch { /* no config file */ }
- return c.json({ services });
- });
- app.get("/api/v1/services/config", async (c) => {
- const config = await loadRawConfig(root);
- const llm = (config.llm as Record<string, unknown> | undefined) ?? {};
- const services = normalizeServiceConfig(llm.services);
- const envConfig = await readEnvConfigStatus(root);
- return c.json({
- services,
- defaultModel: llm.defaultModel ?? null,
- configSource: normalizeConfigSource(llm.configSource),
- envConfig,
- });
- });
- app.put("/api/v1/services/config", async (c) => {
- const body = await c.req.json<{ services?: unknown; defaultModel?: string; configSource?: LLMConfigSource }>();
- const config = await loadRawConfig(root);
- config.llm = config.llm ?? {};
- const llm = config.llm as Record<string, unknown>;
- if (body.services !== undefined) {
- const existingServices = normalizeServiceConfig(llm.services);
- const incomingServices = normalizeServiceConfig(body.services);
- llm.services = mergeServiceConfig(existingServices, incomingServices);
- }
- if (body.defaultModel !== undefined) {
- llm.defaultModel = body.defaultModel;
- }
- if (body.configSource !== undefined) {
- llm.configSource = normalizeConfigSource(body.configSource);
- }
- await saveRawConfig(root, config);
- return c.json({ ok: true });
- });
- app.post("/api/v1/services/:service/test", async (c) => {
- const service = c.req.param("service");
- const { apiKey, baseUrl, apiFormat, stream } = await c.req.json<{
- apiKey: string;
- baseUrl?: string;
- apiFormat?: "chat" | "responses";
- stream?: boolean;
- }>();
- if (!apiKey?.trim()) {
- return c.json({ ok: false, error: "API Key 不能为空" }, 400);
- }
- const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service, baseUrl);
- if (!resolvedBaseUrl) {
- return c.json({ ok: false, error: `未知服务商: ${service}` }, 400);
- }
- // Call the real /models API — no fallback
- const modelsUrl = resolvedBaseUrl.replace(/\/$/, "") + "/models";
- try {
- const res = await fetch(modelsUrl, {
- headers: { Authorization: `Bearer ${apiKey.trim()}` },
- signal: AbortSignal.timeout(10_000),
- });
- if (!res.ok) {
- const body = await res.text().catch(() => "");
- if (res.status === 401 || res.status === 403) {
- return c.json({ ok: false, error: "API Key 无效,请检查后重试" }, 400);
- }
- return c.json({ ok: false, error: `服务商返回 ${res.status}: ${body.slice(0, 200)}` }, 400);
- }
- const json = await res.json() as { data?: Array<{ id: string; owned_by?: string }> };
- const models = (json.data ?? []).map((m) => ({ id: m.id, name: m.id }));
- if (models.length === 0) {
- return c.json({ ok: false, error: "连接成功但未返回可用模型" }, 400);
- }
- const rawConfig = await loadRawConfig(root).catch(() => ({} as Record<string, unknown>));
- const preferredModel = typeof (rawConfig.llm as Record<string, unknown> | undefined)?.defaultModel === "string"
- ? String((rawConfig.llm as Record<string, unknown>).defaultModel)
- : undefined;
- const sampleModel = models.find((m) => m.id === preferredModel)?.id
- ?? models.find((m) => m.id === "gpt-5.4")?.id
- ?? models[0]?.id;
- if (sampleModel) {
- const client = createLLMClient({
- provider: service === "anthropic" ? "anthropic" : "openai",
- service: isCustomServiceId(service) ? "custom" : service,
- configSource: "studio",
- baseUrl: resolvedBaseUrl,
- apiKey: apiKey.trim(),
- model: sampleModel,
- temperature: 0.7,
- maxTokens: 64,
- thinkingBudget: 0,
- apiFormat: apiFormat ?? "chat",
- stream: stream ?? true,
- } as ProjectConfig["llm"]);
- try {
- await chatCompletion(
- client,
- sampleModel,
- [{ role: "user", content: "ping" }],
- { maxTokens: 5 },
- );
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- return c.json({ ok: false, error: `模型列表可读,但生成测试失败:${message}` }, 400);
- }
- }
- return c.json({ ok: true, modelCount: models.length, models: models.slice(0, 50) });
- } catch (err: any) {
- if (err?.name === "TimeoutError" || err?.name === "AbortError") {
- return c.json({ ok: false, error: `连接超时:无法访问 ${modelsUrl}` }, 400);
- }
- return c.json({ ok: false, error: `连接失败: ${err?.message ?? String(err)}` }, 400);
- }
- });
- app.put("/api/v1/services/:service/secret", async (c) => {
- const service = c.req.param("service");
- const { apiKey } = await c.req.json<{ apiKey: string }>();
- const secrets = await loadSecrets(root);
- if (apiKey?.trim()) {
- secrets.services[service] = { apiKey: apiKey.trim() };
- } else {
- delete secrets.services[service];
- }
- await saveSecrets(root, secrets);
- return c.json({ ok: true });
- });
- app.get("/api/v1/services/:service/models", async (c) => {
- const service = c.req.param("service");
- const apiKey = c.req.query("apiKey") || await getServiceApiKey(root, service);
- // No key = no models (don't fallback to built-in list)
- if (!apiKey) return c.json({ models: [] });
- const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
- if (!resolvedBaseUrl) return c.json({ models: [] });
- // Call real API only
- let models: Array<{ id: string; name: string }> = [];
- try {
- const modelsUrl = resolvedBaseUrl.replace(/\/$/, "") + "/models";
- const res = await fetch(modelsUrl, {
- headers: { Authorization: `Bearer ${apiKey}` },
- signal: AbortSignal.timeout(10_000),
- });
- if (res.ok) {
- const json = await res.json() as { data?: Array<{ id: string }> };
- models = (json.data ?? []).map((m) => ({ id: m.id, name: m.id }));
- }
- } catch { /* timeout or network error — return empty */ }
- return c.json({ models });
- });
- // --- Project info ---
- app.get("/api/v1/project", async (c) => {
- const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
- // Check if language was explicitly set in inkos.json (not just the schema default)
- const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
- const languageExplicit = "language" in raw && raw.language !== "";
- return c.json({
- name: currentConfig.name,
- language: currentConfig.language,
- languageExplicit,
- model: currentConfig.llm.model,
- provider: currentConfig.llm.provider,
- baseUrl: currentConfig.llm.baseUrl,
- stream: currentConfig.llm.stream,
- temperature: currentConfig.llm.temperature,
- maxTokens: currentConfig.llm.maxTokens,
- });
- });
- // --- Config editing ---
- app.put("/api/v1/project", async (c) => {
- const updates = await c.req.json<Record<string, unknown>>();
- const configPath = join(root, "inkos.json");
- try {
- const raw = await readFile(configPath, "utf-8");
- const existing = JSON.parse(raw);
- // Merge LLM settings
- if (updates.temperature !== undefined) {
- existing.llm.temperature = updates.temperature;
- }
- if (updates.maxTokens !== undefined) {
- existing.llm.maxTokens = updates.maxTokens;
- }
- if (updates.stream !== undefined) {
- existing.llm.stream = updates.stream;
- }
- if (updates.language === "zh" || updates.language === "en") {
- existing.language = updates.language;
- }
- const { writeFile: writeFileFs } = await import("node:fs/promises");
- await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
- return c.json({ ok: true });
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Truth files browser ---
- app.get("/api/v1/books/:id/truth", async (c) => {
- const id = c.req.param("id");
- const bookDir = state.bookDir(id);
- const storyDir = join(bookDir, "story");
- try {
- const files = await readdir(storyDir);
- const mdFiles = files.filter((f) => f.endsWith(".md") || f.endsWith(".json"));
- const result = await Promise.all(
- mdFiles.map(async (f) => {
- const content = await readFile(join(storyDir, f), "utf-8");
- return { name: f, size: content.length, preview: content.slice(0, 200) };
- }),
- );
- return c.json({ files: result });
- } catch {
- return c.json({ files: [] });
- }
- });
- // --- Daemon control ---
- let schedulerInstance: import("@actalk/inkos-core").Scheduler | null = null;
- app.get("/api/v1/daemon", (c) => {
- return c.json({
- running: schedulerInstance?.isRunning ?? false,
- });
- });
- app.post("/api/v1/daemon/start", async (c) => {
- if (schedulerInstance?.isRunning) {
- return c.json({ error: "Daemon already running" }, 400);
- }
- try {
- const { Scheduler } = await import("@actalk/inkos-core");
- const currentConfig = await loadCurrentProjectConfig();
- const scheduler = new Scheduler({
- ...(await buildPipelineConfig()),
- radarCron: currentConfig.daemon.schedule.radarCron,
- writeCron: currentConfig.daemon.schedule.writeCron,
- maxConcurrentBooks: currentConfig.daemon.maxConcurrentBooks,
- chaptersPerCycle: currentConfig.daemon.chaptersPerCycle,
- retryDelayMs: currentConfig.daemon.retryDelayMs,
- cooldownAfterChapterMs: currentConfig.daemon.cooldownAfterChapterMs,
- maxChaptersPerDay: currentConfig.daemon.maxChaptersPerDay,
- onChapterComplete: (bookId, chapter, status) => {
- broadcast("daemon:chapter", { bookId, chapter, status });
- },
- onError: (bookId, error) => {
- broadcast("daemon:error", { bookId, error: error.message });
- },
- });
- schedulerInstance = scheduler;
- broadcast("daemon:started", {});
- void scheduler.start().catch((e) => {
- const error = e instanceof Error ? e : new Error(String(e));
- if (schedulerInstance === scheduler) {
- scheduler.stop();
- schedulerInstance = null;
- broadcast("daemon:stopped", {});
- }
- broadcast("daemon:error", { bookId: "scheduler", error: error.message });
- });
- return c.json({ ok: true, running: true });
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- app.post("/api/v1/daemon/stop", (c) => {
- if (!schedulerInstance?.isRunning) {
- return c.json({ error: "Daemon not running" }, 400);
- }
- schedulerInstance.stop();
- schedulerInstance = null;
- broadcast("daemon:stopped", {});
- return c.json({ ok: true, running: false });
- });
- // --- Logs ---
- app.get("/api/v1/logs", async (c) => {
- const logPath = join(root, "inkos.log");
- try {
- const content = await readFile(logPath, "utf-8");
- const lines = content.trim().split("\n").slice(-100);
- const entries = lines.map((line) => {
- try { return JSON.parse(line); } catch { return { message: line }; }
- });
- return c.json({ entries });
- } catch {
- return c.json({ entries: [] });
- }
- });
- // --- Agent chat ---
- app.get("/api/v1/interaction/session", async (c) => {
- const session = await loadProjectSession(root);
- const activeBookId = await resolveSessionActiveBook(root, session);
- return c.json({
- session: activeBookId && session.activeBookId !== activeBookId
- ? { ...session, activeBookId }
- : session,
- activeBookId,
- });
- });
- // -- Per-book session endpoints --
- app.get("/api/v1/sessions", async (c) => {
- const bookId = c.req.query("bookId");
- const sessions = await listBookSessions(root, bookId === undefined ? null : bookId === "null" ? null : bookId);
- return c.json({ sessions: sessions.map((s) => ({
- sessionId: s.sessionId,
- bookId: s.bookId,
- messageCount: s.messages.length,
- createdAt: s.createdAt,
- updatedAt: s.updatedAt,
- })) });
- });
- app.get("/api/v1/sessions/:sessionId", async (c) => {
- const session = await loadBookSession(root, c.req.param("sessionId"));
- if (!session) return c.json({ error: "Session not found" }, 404);
- return c.json({ session });
- });
- app.post("/api/v1/sessions", async (c) => {
- const body = await c.req.json<{ bookId?: string | null }>().catch(() => ({}));
- const bookId = (body as { bookId?: string | null }).bookId ?? null;
- const session = await findOrCreateBookSession(root, bookId);
- return c.json({ session });
- });
- app.post("/api/v1/agent", async (c) => {
- const { instruction, activeBookId, sessionId: reqSessionId, model: reqModel, service: reqService } = await c.req.json<{
- instruction: string;
- activeBookId?: string;
- sessionId?: string;
- model?: string;
- service?: string;
- }>();
- const sessionId = reqSessionId;
- if (!instruction?.trim()) {
- return c.json({ error: "No instruction provided" }, 400);
- }
- broadcast("agent:start", { instruction, activeBookId });
- try {
- // Load config + create LLM client (pipeline created after model resolution)
- const config = await loadCurrentProjectConfig({ requireApiKey: false });
- const client = createLLMClient(config.llm);
- // Resolve or create BookSession for history
- let bookSession: BookSession;
- if (sessionId) {
- bookSession =
- (await loadBookSession(root, sessionId)) ??
- (await findOrCreateBookSession(root, activeBookId ?? null));
- } else {
- bookSession = await findOrCreateBookSession(root, activeBookId ?? null);
- }
- // Build initial message context from persisted session
- const initialMessages = bookSession.messages
- .filter((m) => m.role === "user" || m.role === "assistant")
- .map((m) => ({ role: m.role as "user" | "assistant", content: m.content }));
- // Resolve model — multi-service resolution
- let resolvedModel: ResolvedModel["model"] | undefined;
- let resolvedApiKey: string | undefined;
- if (reqService && reqModel) {
- // 1. Frontend explicitly selected a service+model — fail loudly if no key
- try {
- const configuredEntry = await resolveConfiguredServiceEntry(root, reqService);
- const resolved = await resolveServiceModel(
- reqService,
- reqModel,
- root,
- await resolveConfiguredServiceBaseUrl(root, reqService),
- configuredEntry?.apiFormat,
- );
- resolvedModel = resolved.model;
- resolvedApiKey = resolved.apiKey;
- } catch (e: any) {
- const msg = e?.message ?? String(e);
- if (/API key/i.test(msg)) {
- return c.json({
- error: `请先为 ${reqService} 配置 API Key`,
- response: `请先在模型配置中为 ${reqService} 填写 API Key,然后再试。`,
- }, 400);
- }
- throw e;
- }
- }
- if (!resolvedModel) {
- // 2. Try defaultModel from new config format
- const rawConfig = config.llm as unknown as Record<string, unknown>;
- const defaultModel = rawConfig.defaultModel as string | undefined;
- const servicesArr = normalizeServiceConfig(rawConfig.services);
- const firstService = servicesArr[0];
- if (firstService?.service && defaultModel) {
- try {
- const resolved = await resolveServiceModel(
- serviceConfigKey(firstService),
- defaultModel,
- root,
- firstService.baseUrl,
- firstService.apiFormat,
- );
- resolvedModel = resolved.model;
- resolvedApiKey = resolved.apiKey;
- } catch { /* fall through */ }
- }
- }
- if (!resolvedModel) {
- // 3. Try first connected service from secrets
- const secrets = await loadSecrets(root);
- for (const [svcName, svcData] of Object.entries(secrets.services)) {
- if (svcData?.apiKey) {
- try {
- const models = await listModelsForService(svcName, svcData.apiKey);
- if (models.length > 0) {
- const configuredEntry = await resolveConfiguredServiceEntry(root, svcName);
- const resolved = await resolveServiceModel(
- svcName,
- models[0].id,
- root,
- await resolveConfiguredServiceBaseUrl(root, svcName),
- configuredEntry?.apiFormat,
- );
- resolvedModel = resolved.model;
- resolvedApiKey = resolved.apiKey;
- break;
- }
- } catch { /* try next */ }
- }
- }
- }
- if (!resolvedModel) {
- // 4. Legacy fallback: use createLLMClient
- resolvedModel = client._piModel
- ? client._piModel
- : { provider: config.llm.provider ?? "anthropic", modelId: config.llm.model } as any;
- resolvedApiKey = client._apiKey;
- }
- const model = resolvedModel!;
- const agentApiKey = resolvedApiKey;
- const configuredEntry = reqService ? await resolveConfiguredServiceEntry(root, reqService) : undefined;
- // Create pipeline with resolved model (so sub_agent tools use the frontend-selected model)
- // Don't spread config.llm — its baseUrl/provider belong to the old service.
- // Let createLLMClient resolve baseUrl from the service preset.
- const pipelineClient = (reqService && reqModel && resolvedApiKey)
- ? createLLMClient({
- ...config.llm,
- service: configuredEntry?.service ?? reqService,
- model: reqModel,
- apiKey: resolvedApiKey,
- ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
- ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
- baseUrl: configuredEntry?.baseUrl ?? "",
- } as any)
- : client;
- const pipeline = new PipelineRunner(await buildPipelineConfig({
- client: pipelineClient,
- model: reqModel ?? config.llm.model,
- currentConfig: config,
- }));
- // Run pi-agent session
- const collectedToolExecs: CollectedToolExec[] = [];
- const result = await runAgentSession(
- {
- model,
- apiKey: agentApiKey,
- pipeline,
- projectRoot: root,
- bookId: activeBookId ?? null,
- sessionId: bookSession.sessionId,
- language: config.language ?? "zh",
- onEvent: (event) => {
- if (event.type === "message_update") {
- const ame = event.assistantMessageEvent;
- if (ame.type === "text_delta") {
- broadcast("draft:delta", { text: ame.delta });
- } else if (ame.type === "thinking_delta") {
- broadcast("thinking:delta", { text: (ame as any).delta });
- } else if (ame.type === "thinking_start") {
- broadcast("thinking:start", {});
- } else if (ame.type === "thinking_end") {
- broadcast("thinking:end", {});
- }
- }
- if (event.type === "tool_execution_start") {
- const args = event.args as Record<string, unknown> | undefined;
- const agent = event.toolName === "sub_agent" ? (args?.agent as string | undefined) : undefined;
- const stages = agent ? (PIPELINE_STAGES[agent] ?? []) : [];
- collectedToolExecs.push({
- id: event.toolCallId,
- tool: event.toolName,
- agent,
- label: resolveToolLabel(event.toolName, agent),
- status: "running",
- args,
- stages: stages.length > 0
- ? stages.map(l => ({ label: l, status: "pending" as const }))
- : undefined,
- startedAt: Date.now(),
- });
- broadcast("tool:start", {
- id: event.toolCallId,
- tool: event.toolName,
- args,
- stages,
- });
- }
- if (event.type === "tool_execution_update") {
- broadcast("tool:update", { tool: event.toolName, partialResult: event.partialResult });
- }
- if (event.type === "tool_execution_end") {
- const exec = collectedToolExecs.find(t => t.id === event.toolCallId);
- if (exec) {
- exec.status = event.isError ? "error" : "completed";
- exec.completedAt = Date.now();
- exec.stages = exec.stages?.map(s => ({ ...s, status: "completed" as const }));
- if (event.isError) exec.error = extractToolError(event.result);
- else exec.result = summarizeResult(event.result);
- }
- broadcast("tool:end", {
- id: event.toolCallId,
- tool: event.toolName,
- result: event.result,
- isError: event.isError,
- });
- }
- },
- },
- instruction,
- initialMessages,
- );
- // Persist user + assistant messages to BookSession
- bookSession = appendBookSessionMessage(bookSession, {
- role: "user",
- content: instruction,
- timestamp: Date.now(),
- });
- if (result.responseText) {
- const lastAssistant = result.messages?.filter((m: any) => m.role === "assistant").pop();
- const thinking = lastAssistant?.thinking;
- bookSession = appendBookSessionMessage(bookSession, {
- role: "assistant",
- content: result.responseText,
- ...(thinking ? { thinking } : {}),
- ...(collectedToolExecs.length > 0 ? { toolExecutions: collectedToolExecs } : {}),
- timestamp: Date.now() + 1,
- });
- }
- if (!result.responseText) {
- try {
- const fallbackClient = createLLMClient({
- ...config.llm,
- service: configuredEntry?.service ?? reqService ?? config.llm.service,
- model: reqModel ?? config.llm.model,
- apiKey: agentApiKey ?? config.llm.apiKey,
- baseUrl: configuredEntry?.baseUrl ?? config.llm.baseUrl,
- ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
- ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
- } as ProjectConfig["llm"]);
- const fallback = await chatCompletion(
- fallbackClient,
- reqModel ?? config.llm.model,
- [
- { role: "system", content: buildAgentSystemPrompt(activeBookId ?? null, config.language ?? "zh") },
- { role: "user", content: instruction },
- ],
- { maxTokens: 256 },
- );
- if (fallback.content?.trim()) {
- bookSession = appendBookSessionMessage(bookSession, {
- role: "assistant",
- content: fallback.content,
- timestamp: Date.now() + 1,
- });
- await persistBookSession(root, bookSession);
- return c.json({
- response: fallback.content,
- session: { sessionId: bookSession.sessionId },
- });
- }
- } catch {
- // fall through to probe-based diagnosis below
- }
- try {
- const probeClient = createLLMClient({
- ...config.llm,
- service: configuredEntry?.service ?? reqService ?? config.llm.service,
- model: reqModel ?? config.llm.model,
- apiKey: agentApiKey ?? config.llm.apiKey,
- baseUrl: configuredEntry?.baseUrl ?? config.llm.baseUrl,
- ...(configuredEntry?.apiFormat ? { apiFormat: configuredEntry.apiFormat } : {}),
- ...(configuredEntry?.stream !== undefined ? { stream: configuredEntry.stream } : {}),
- } as ProjectConfig["llm"]);
- await chatCompletion(
- probeClient,
- reqModel ?? config.llm.model,
- [{ role: "user", content: "ping" }],
- { maxTokens: 5 },
- );
- } catch (probeError) {
- const probeMessage = probeError instanceof Error ? probeError.message : String(probeError);
- return c.json({
- error: { code: "AGENT_EMPTY_RESPONSE", message: probeMessage },
- response: probeMessage,
- }, 502);
- }
- const emptyMessage = "模型未返回文本内容。请检查协议类型(chat/responses)、流式开关或上游服务兼容性。";
- return c.json({
- error: { code: "AGENT_EMPTY_RESPONSE", message: emptyMessage },
- response: emptyMessage,
- }, 502);
- }
- await persistBookSession(root, bookSession);
- broadcast("agent:complete", { instruction, activeBookId });
- return c.json({
- response: result.responseText,
- session: { sessionId: bookSession.sessionId },
- });
- } catch (e) {
- const msg = e instanceof Error ? e.message : String(e);
- broadcast("agent:error", { instruction, activeBookId, error: msg });
- // Agent busy — return 429 with user-friendly message
- if (/already processing|prompt.*queue/i.test(msg)) {
- return c.json({
- error: { code: "AGENT_BUSY", message: "正在处理中,请等待当前操作完成" },
- response: "正在处理中,请等待当前操作完成后再发送。",
- }, 429);
- }
- return c.json(
- { error: { code: "AGENT_ERROR", message: msg } },
- 500,
- );
- }
- });
- // --- Language setup ---
- app.post("/api/v1/project/language", async (c) => {
- const { language } = await c.req.json<{ language: "zh" | "en" }>();
- const configPath = join(root, "inkos.json");
- try {
- const raw = await readFile(configPath, "utf-8");
- const existing = JSON.parse(raw);
- existing.language = language;
- const { writeFile: writeFileFs } = await import("node:fs/promises");
- await writeFileFs(configPath, JSON.stringify(existing, null, 2), "utf-8");
- return c.json({ ok: true, language });
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Audit ---
- app.post("/api/v1/books/:id/audit/:chapter", async (c) => {
- const id = c.req.param("id");
- const chapterNum = parseInt(c.req.param("chapter"), 10);
- const bookDir = state.bookDir(id);
- broadcast("audit:start", { bookId: id, chapter: chapterNum });
- try {
- const book = await state.loadBookConfig(id);
- const chaptersDir = join(bookDir, "chapters");
- const files = await readdir(chaptersDir);
- const paddedNum = String(chapterNum).padStart(4, "0");
- const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
- if (!match) return c.json({ error: "Chapter not found" }, 404);
- const content = await readFile(join(chaptersDir, match), "utf-8");
- const currentConfig = await loadCurrentProjectConfig();
- const { ContinuityAuditor } = await import("@actalk/inkos-core");
- const auditor = new ContinuityAuditor({
- client: createLLMClient(currentConfig.llm),
- model: currentConfig.llm.model,
- projectRoot: root,
- bookId: id,
- });
- const result = await auditor.auditChapter(bookDir, content, chapterNum, book.genre);
- broadcast("audit:complete", { bookId: id, chapter: chapterNum, passed: result.passed });
- return c.json(result);
- } catch (e) {
- broadcast("audit:error", { bookId: id, error: String(e) });
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Revise ---
- app.post("/api/v1/books/:id/revise/:chapter", async (c) => {
- const id = c.req.param("id");
- const chapterNum = parseInt(c.req.param("chapter"), 10);
- const bookDir = state.bookDir(id);
- const body = await c.req
- .json<{ mode?: string; brief?: string }>()
- .catch(() => ({ mode: "spot-fix", brief: undefined }));
- broadcast("revise:start", { bookId: id, chapter: chapterNum });
- try {
- const book = await state.loadBookConfig(id);
- const chaptersDir = join(bookDir, "chapters");
- const files = await readdir(chaptersDir);
- const paddedNum = String(chapterNum).padStart(4, "0");
- const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
- if (!match) return c.json({ error: "Chapter not found" }, 404);
- const pipeline = new PipelineRunner(await buildPipelineConfig({
- externalContext: body.brief,
- }));
- const normalizedMode = body.mode ?? "spot-fix";
- const result = await pipeline.reviseDraft(
- id,
- chapterNum,
- normalizedMode as "polish" | "rewrite" | "rework" | "spot-fix" | "anti-detect",
- );
- broadcast("revise:complete", { bookId: id, chapter: chapterNum });
- return c.json(result);
- } catch (e) {
- broadcast("revise:error", { bookId: id, error: String(e) });
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Export ---
- app.get("/api/v1/books/:id/export", async (c) => {
- const id = c.req.param("id");
- const format = (c.req.query("format") ?? "txt") as string;
- const approvedOnly = c.req.query("approvedOnly") === "true";
- const bookDir = state.bookDir(id);
- const chaptersDir = join(bookDir, "chapters");
- try {
- const book = await state.loadBookConfig(id);
- const index = await state.loadChapterIndex(id);
- const approvedNums = new Set(
- approvedOnly ? index.filter((ch) => ch.status === "approved").map((ch) => ch.number) : [],
- );
- const files = await readdir(chaptersDir);
- const mdFiles = files.filter((f) => f.endsWith(".md") && /^\d{4}/.test(f)).sort();
- const filteredFiles = approvedOnly
- ? mdFiles.filter((f) => approvedNums.has(parseInt(f.slice(0, 4), 10)))
- : mdFiles;
- const contents = await Promise.all(
- filteredFiles.map((f) => readFile(join(chaptersDir, f), "utf-8")),
- );
- if (format === "epub") {
- // Basic EPUB: XHTML container
- const chapters = contents.map((content, i) => {
- const title = content.match(/^#\s+(.+)$/m)?.[1] ?? `Chapter ${i + 1}`;
- const html = content.split("\n").filter((l) => !l.startsWith("#")).map((l) => l.trim() ? `<p>${l}</p>` : "").join("\n");
- return { title, html };
- });
- const toc = chapters.map((ch, i) => `<li><a href="#ch${i}">${ch.title}</a></li>`).join("\n");
- const body = chapters.map((ch, i) => `<h2 id="ch${i}">${ch.title}</h2>\n${ch.html}`).join("\n<hr/>\n");
- const epub = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${book.title}</title><style>body{font-family:serif;max-width:40em;margin:auto;padding:2em;line-height:1.8}h2{margin-top:3em}</style></head><body><h1>${book.title}</h1><nav><ol>${toc}</ol></nav><hr/>${body}</body></html>`;
- return new Response(epub, {
- headers: {
- "Content-Type": "text/html; charset=utf-8",
- "Content-Disposition": `attachment; filename="${id}.html"`,
- },
- });
- }
- if (format === "md") {
- const body = contents.join("\n\n---\n\n");
- return new Response(body, {
- headers: {
- "Content-Type": "text/markdown; charset=utf-8",
- "Content-Disposition": `attachment; filename="${id}.md"`,
- },
- });
- }
- // Default: txt
- const body = contents.join("\n\n");
- return new Response(body, {
- headers: {
- "Content-Type": "text/plain; charset=utf-8",
- "Content-Disposition": `attachment; filename="${id}.txt"`,
- },
- });
- } catch {
- return c.json({ error: "Export failed" }, 500);
- }
- });
- // --- Export to file (save to project dir) ---
- app.post("/api/v1/books/:id/export-save", async (c) => {
- const id = c.req.param("id");
- const { format, approvedOnly } = await c.req.json<{ format?: string; approvedOnly?: boolean }>().catch(() => ({ format: "txt", approvedOnly: false }));
- const fmt = format ?? "txt";
- try {
- const pipeline = new PipelineRunner(await buildPipelineConfig());
- const tools = createInteractionToolsFromDeps(pipeline, state);
- const bookDir = state.bookDir(id);
- const outputPath = join(bookDir, `${id}.${fmt === "epub" ? "epub" : fmt}`);
- const result = await processProjectInteractionRequest({
- projectRoot: root,
- request: {
- intent: "export_book",
- bookId: id,
- format: fmt as "txt" | "md" | "epub",
- approvedOnly,
- outputPath,
- },
- tools,
- activeBookId: id,
- });
- return c.json({
- ok: true,
- path: (result.details?.outputPath as string | undefined) ?? outputPath,
- format: fmt,
- chapters: (result.details?.chaptersExported as number | undefined) ?? 0,
- });
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Genre detail + copy ---
- app.get("/api/v1/genres/:id", async (c) => {
- const genreId = c.req.param("id");
- try {
- const { readGenreProfile } = await import("@actalk/inkos-core");
- const { profile, body } = await readGenreProfile(root, genreId);
- return c.json({ profile, body });
- } catch (e) {
- return c.json({ error: String(e) }, 404);
- }
- });
- app.post("/api/v1/genres/:id/copy", async (c) => {
- const genreId = c.req.param("id");
- if (/[/\\\0]/.test(genreId) || genreId.includes("..")) {
- throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${genreId}"`);
- }
- try {
- const { getBuiltinGenresDir } = await import("@actalk/inkos-core");
- const { mkdir: mkdirFs, copyFile } = await import("node:fs/promises");
- const builtinDir = getBuiltinGenresDir();
- const projectGenresDir = join(root, "genres");
- await mkdirFs(projectGenresDir, { recursive: true });
- await copyFile(join(builtinDir, `${genreId}.md`), join(projectGenresDir, `${genreId}.md`));
- return c.json({ ok: true, path: `genres/${genreId}.md` });
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Model overrides ---
- app.get("/api/v1/project/model-overrides", async (c) => {
- const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
- return c.json({ overrides: raw.modelOverrides ?? {} });
- });
- app.put("/api/v1/project/model-overrides", async (c) => {
- const { overrides } = await c.req.json<{ overrides: Record<string, unknown> }>();
- const configPath = join(root, "inkos.json");
- const raw = JSON.parse(await readFile(configPath, "utf-8"));
- raw.modelOverrides = overrides;
- const { writeFile: writeFileFs } = await import("node:fs/promises");
- await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
- return c.json({ ok: true });
- });
- // --- Notify channels ---
- app.get("/api/v1/project/notify", async (c) => {
- const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
- return c.json({ channels: raw.notify ?? [] });
- });
- app.put("/api/v1/project/notify", async (c) => {
- const { channels } = await c.req.json<{ channels: unknown[] }>();
- const configPath = join(root, "inkos.json");
- const raw = JSON.parse(await readFile(configPath, "utf-8"));
- raw.notify = channels;
- const { writeFile: writeFileFs } = await import("node:fs/promises");
- await writeFileFs(configPath, JSON.stringify(raw, null, 2), "utf-8");
- return c.json({ ok: true });
- });
- // --- AIGC Detection ---
- app.post("/api/v1/books/:id/detect/:chapter", async (c) => {
- const id = c.req.param("id");
- const chapterNum = parseInt(c.req.param("chapter"), 10);
- const bookDir = state.bookDir(id);
- try {
- const chaptersDir = join(bookDir, "chapters");
- const files = await readdir(chaptersDir);
- const paddedNum = String(chapterNum).padStart(4, "0");
- const match = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
- if (!match) return c.json({ error: "Chapter not found" }, 404);
- const content = await readFile(join(chaptersDir, match), "utf-8");
- const { analyzeAITells } = await import("@actalk/inkos-core");
- const result = analyzeAITells(content);
- return c.json({ chapterNumber: chapterNum, ...result });
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Truth file edit ---
- app.put("/api/v1/books/:id/truth/:file", async (c) => {
- const id = c.req.param("id");
- const file = c.req.param("file");
- if (!TRUTH_FILES.includes(file)) {
- return c.json({ error: "Invalid truth file" }, 400);
- }
- const { content } = await c.req.json<{ content: string }>();
- const bookDir = state.bookDir(id);
- const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
- await mkdirFs(join(bookDir, "story"), { recursive: true });
- await writeFileFs(join(bookDir, "story", file), content, "utf-8");
- return c.json({ ok: true });
- });
- // =============================================
- // NEW ENDPOINTS — CLI parity
- // =============================================
- // --- Book Delete ---
- app.delete("/api/v1/books/:id", async (c) => {
- const id = c.req.param("id");
- const bookDir = state.bookDir(id);
- try {
- const { rm } = await import("node:fs/promises");
- await rm(bookDir, { recursive: true, force: true });
- broadcast("book:deleted", { bookId: id });
- return c.json({ ok: true, bookId: id });
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Book Update ---
- app.put("/api/v1/books/:id", async (c) => {
- const id = c.req.param("id");
- const updates = await c.req.json<{
- chapterWordCount?: number;
- targetChapters?: number;
- status?: string;
- language?: string;
- }>();
- try {
- const book = await state.loadBookConfig(id);
- const updated = {
- ...book,
- ...(updates.chapterWordCount !== undefined ? { chapterWordCount: Number(updates.chapterWordCount) } : {}),
- ...(updates.targetChapters !== undefined ? { targetChapters: Number(updates.targetChapters) } : {}),
- ...(updates.status !== undefined ? { status: updates.status as typeof book.status } : {}),
- ...(updates.language !== undefined ? { language: updates.language as "zh" | "en" } : {}),
- updatedAt: new Date().toISOString(),
- };
- await state.saveBookConfig(id, updated);
- return c.json({ ok: true, book: updated });
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Write Rewrite (specific chapter) ---
- app.post("/api/v1/books/:id/rewrite/:chapter", async (c) => {
- const id = c.req.param("id");
- const chapterNum = parseInt(c.req.param("chapter"), 10);
- const body: { brief?: string } = await c.req
- .json<{ brief?: string }>()
- .catch(() => ({}));
- broadcast("rewrite:start", { bookId: id, chapter: chapterNum });
- try {
- const rollbackTarget = chapterNum - 1;
- const discarded = await state.rollbackToChapter(id, rollbackTarget);
- const pipeline = new PipelineRunner(await buildPipelineConfig({
- externalContext: body.brief,
- }));
- pipeline.writeNextChapter(id).then(
- (result) => broadcast("rewrite:complete", { bookId: id, chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount }),
- (e) => broadcast("rewrite:error", { bookId: id, error: e instanceof Error ? e.message : String(e) }),
- );
- return c.json({ status: "rewriting", bookId: id, chapter: chapterNum, rolledBackTo: rollbackTarget, discarded });
- } catch (e) {
- broadcast("rewrite:error", { bookId: id, error: String(e) });
- return c.json({ error: String(e) }, 500);
- }
- });
- app.post("/api/v1/books/:id/resync/:chapter", async (c) => {
- const id = c.req.param("id");
- const chapterNum = parseInt(c.req.param("chapter"), 10);
- const body: { brief?: string } = await c.req
- .json<{ brief?: string }>()
- .catch(() => ({}));
- try {
- const pipeline = new PipelineRunner(await buildPipelineConfig({
- externalContext: body.brief,
- }));
- const result = await pipeline.resyncChapterArtifacts(id, chapterNum);
- return c.json(result);
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Detect All chapters ---
- app.post("/api/v1/books/:id/detect-all", async (c) => {
- const id = c.req.param("id");
- const bookDir = state.bookDir(id);
- try {
- const chaptersDir = join(bookDir, "chapters");
- const files = await readdir(chaptersDir);
- const mdFiles = files.filter((f) => f.endsWith(".md") && /^\d{4}/.test(f)).sort();
- const { analyzeAITells } = await import("@actalk/inkos-core");
- const results = await Promise.all(
- mdFiles.map(async (f) => {
- const num = parseInt(f.slice(0, 4), 10);
- const content = await readFile(join(chaptersDir, f), "utf-8");
- const result = analyzeAITells(content);
- return { chapterNumber: num, filename: f, ...result };
- }),
- );
- return c.json({ bookId: id, results });
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Detect Stats ---
- app.get("/api/v1/books/:id/detect/stats", async (c) => {
- const id = c.req.param("id");
- try {
- const { loadDetectionHistory, analyzeDetectionInsights } = await import("@actalk/inkos-core");
- const bookDir = state.bookDir(id);
- const history = await loadDetectionHistory(bookDir);
- const insights = analyzeDetectionInsights(history);
- return c.json(insights);
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Genre Create ---
- app.post("/api/v1/genres/create", async (c) => {
- const body = await c.req.json<{
- id: string; name: string; language?: string;
- chapterTypes?: string[]; fatigueWords?: string[];
- numericalSystem?: boolean; powerScaling?: boolean; eraResearch?: boolean;
- pacingRule?: string; satisfactionTypes?: string[]; auditDimensions?: number[];
- body?: string;
- }>();
- if (!body.id || !body.name) {
- return c.json({ error: "id and name are required" }, 400);
- }
- if (/[/\\\0]/.test(body.id) || body.id.includes("..")) {
- throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${body.id}"`);
- }
- const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
- const genresDir = join(root, "genres");
- await mkdirFs(genresDir, { recursive: true });
- const frontmatter = [
- "---",
- `name: ${body.name}`,
- `id: ${body.id}`,
- `language: ${body.language ?? "zh"}`,
- `chapterTypes: ${JSON.stringify(body.chapterTypes ?? [])}`,
- `fatigueWords: ${JSON.stringify(body.fatigueWords ?? [])}`,
- `numericalSystem: ${body.numericalSystem ?? false}`,
- `powerScaling: ${body.powerScaling ?? false}`,
- `eraResearch: ${body.eraResearch ?? false}`,
- `pacingRule: "${body.pacingRule ?? ""}"`,
- `satisfactionTypes: ${JSON.stringify(body.satisfactionTypes ?? [])}`,
- `auditDimensions: ${JSON.stringify(body.auditDimensions ?? [])}`,
- "---",
- "",
- body.body ?? "",
- ].join("\n");
- await writeFileFs(join(genresDir, `${body.id}.md`), frontmatter, "utf-8");
- return c.json({ ok: true, id: body.id });
- });
- // --- Genre Edit ---
- app.put("/api/v1/genres/:id", async (c) => {
- const genreId = c.req.param("id");
- if (/[/\\\0]/.test(genreId) || genreId.includes("..")) {
- throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${genreId}"`);
- }
- const body = await c.req.json<{ profile: Record<string, unknown>; body: string }>();
- const { writeFile: writeFileFs, mkdir: mkdirFs } = await import("node:fs/promises");
- const genresDir = join(root, "genres");
- await mkdirFs(genresDir, { recursive: true });
- const p = body.profile;
- const frontmatter = [
- "---",
- `name: ${p.name ?? genreId}`,
- `id: ${p.id ?? genreId}`,
- `language: ${p.language ?? "zh"}`,
- `chapterTypes: ${JSON.stringify(p.chapterTypes ?? [])}`,
- `fatigueWords: ${JSON.stringify(p.fatigueWords ?? [])}`,
- `numericalSystem: ${p.numericalSystem ?? false}`,
- `powerScaling: ${p.powerScaling ?? false}`,
- `eraResearch: ${p.eraResearch ?? false}`,
- `pacingRule: "${p.pacingRule ?? ""}"`,
- `satisfactionTypes: ${JSON.stringify(p.satisfactionTypes ?? [])}`,
- `auditDimensions: ${JSON.stringify(p.auditDimensions ?? [])}`,
- "---",
- "",
- body.body ?? "",
- ].join("\n");
- await writeFileFs(join(genresDir, `${genreId}.md`), frontmatter, "utf-8");
- return c.json({ ok: true, id: genreId });
- });
- // --- Genre Delete (project-level only) ---
- app.delete("/api/v1/genres/:id", async (c) => {
- const genreId = c.req.param("id");
- if (/[/\\\0]/.test(genreId) || genreId.includes("..")) {
- throw new ApiError(400, "INVALID_GENRE_ID", `Invalid genre ID: "${genreId}"`);
- }
- const filePath = join(root, "genres", `${genreId}.md`);
- try {
- const { rm } = await import("node:fs/promises");
- await rm(filePath);
- return c.json({ ok: true, id: genreId });
- } catch (e) {
- return c.json({ error: `Genre "${genreId}" not found in project` }, 404);
- }
- });
- // --- Style Analyze ---
- app.post("/api/v1/style/analyze", async (c) => {
- const { text, sourceName } = await c.req.json<{ text: string; sourceName: string }>();
- if (!text?.trim()) return c.json({ error: "text is required" }, 400);
- try {
- const { analyzeStyle } = await import("@actalk/inkos-core");
- const profile = analyzeStyle(text, sourceName ?? "unknown");
- return c.json(profile);
- } catch (e) {
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Style Import to Book ---
- app.post("/api/v1/books/:id/style/import", async (c) => {
- const id = c.req.param("id");
- const { text, sourceName } = await c.req.json<{ text: string; sourceName: string }>();
- broadcast("style:start", { bookId: id });
- try {
- const pipeline = new PipelineRunner(await buildPipelineConfig());
- const result = await pipeline.generateStyleGuide(id, text, sourceName ?? "unknown");
- broadcast("style:complete", { bookId: id });
- return c.json({ ok: true, result });
- } catch (e) {
- broadcast("style:error", { bookId: id, error: String(e) });
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Import Chapters ---
- app.post("/api/v1/books/:id/import/chapters", async (c) => {
- const id = c.req.param("id");
- const { text, splitRegex } = await c.req.json<{ text: string; splitRegex?: string }>();
- if (!text?.trim()) return c.json({ error: "text is required" }, 400);
- broadcast("import:start", { bookId: id, type: "chapters" });
- try {
- const { splitChapters } = await import("@actalk/inkos-core");
- const chapters = [...splitChapters(text, splitRegex)];
- const pipeline = new PipelineRunner(await buildPipelineConfig());
- const result = await pipeline.importChapters({ bookId: id, chapters });
- broadcast("import:complete", { bookId: id, type: "chapters", count: result.importedCount });
- return c.json(result);
- } catch (e) {
- broadcast("import:error", { bookId: id, error: String(e) });
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Import Canon ---
- app.post("/api/v1/books/:id/import/canon", async (c) => {
- const id = c.req.param("id");
- const { fromBookId } = await c.req.json<{ fromBookId: string }>();
- if (!fromBookId) return c.json({ error: "fromBookId is required" }, 400);
- broadcast("import:start", { bookId: id, type: "canon" });
- try {
- const pipeline = new PipelineRunner(await buildPipelineConfig());
- await pipeline.importCanon(id, fromBookId);
- broadcast("import:complete", { bookId: id, type: "canon" });
- return c.json({ ok: true });
- } catch (e) {
- broadcast("import:error", { bookId: id, error: String(e) });
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Fanfic Init ---
- app.post("/api/v1/fanfic/init", async (c) => {
- const body = await c.req.json<{
- title: string; sourceText: string; sourceName?: string;
- mode?: string; genre?: string; platform?: string;
- targetChapters?: number; chapterWordCount?: number; language?: string;
- }>();
- if (!body.title || !body.sourceText) {
- return c.json({ error: "title and sourceText are required" }, 400);
- }
- const now = new Date().toISOString();
- const bookId = body.title.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, "-").replace(/-+/g, "-").slice(0, 30);
- const bookConfig = {
- id: bookId,
- title: body.title,
- platform: (body.platform ?? "other") as "other",
- genre: (body.genre ?? "other") as "xuanhuan",
- status: "outlining" as const,
- targetChapters: body.targetChapters ?? 100,
- chapterWordCount: body.chapterWordCount ?? 3000,
- fanficMode: (body.mode ?? "canon") as "canon",
- ...(body.language ? { language: body.language as "zh" | "en" } : {}),
- createdAt: now,
- updatedAt: now,
- };
- broadcast("fanfic:start", { bookId, title: body.title });
- try {
- const pipeline = new PipelineRunner(await buildPipelineConfig());
- await pipeline.initFanficBook(bookConfig, body.sourceText, body.sourceName ?? "source", (body.mode ?? "canon") as "canon");
- broadcast("fanfic:complete", { bookId });
- return c.json({ ok: true, bookId });
- } catch (e) {
- broadcast("fanfic:error", { bookId, error: String(e) });
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Fanfic Show (read canon) ---
- app.get("/api/v1/books/:id/fanfic", async (c) => {
- const id = c.req.param("id");
- const bookDir = state.bookDir(id);
- try {
- const content = await readFile(join(bookDir, "story", "fanfic_canon.md"), "utf-8");
- return c.json({ bookId: id, content });
- } catch {
- return c.json({ bookId: id, content: null });
- }
- });
- // --- Fanfic Refresh ---
- app.post("/api/v1/books/:id/fanfic/refresh", async (c) => {
- const id = c.req.param("id");
- const { sourceText, sourceName } = await c.req.json<{ sourceText: string; sourceName?: string }>();
- if (!sourceText?.trim()) return c.json({ error: "sourceText is required" }, 400);
- broadcast("fanfic:refresh:start", { bookId: id });
- try {
- const book = await state.loadBookConfig(id);
- const pipeline = new PipelineRunner(await buildPipelineConfig());
- await pipeline.importFanficCanon(id, sourceText, sourceName ?? "source", (book.fanficMode ?? "canon") as "canon");
- broadcast("fanfic:refresh:complete", { bookId: id });
- return c.json({ ok: true });
- } catch (e) {
- broadcast("fanfic:refresh:error", { bookId: id, error: String(e) });
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Radar Scan ---
- app.post("/api/v1/radar/scan", async (c) => {
- broadcast("radar:start", {});
- try {
- const pipeline = new PipelineRunner(await buildPipelineConfig());
- const result = await pipeline.runRadar();
- broadcast("radar:complete", { result });
- return c.json(result);
- } catch (e) {
- broadcast("radar:error", { error: String(e) });
- return c.json({ error: String(e) }, 500);
- }
- });
- // --- Doctor (environment health check) ---
- app.get("/api/v1/doctor", async (c) => {
- const { existsSync } = await import("node:fs");
- const { GLOBAL_ENV_PATH } = await import("@actalk/inkos-core");
- const checks = {
- inkosJson: existsSync(join(root, "inkos.json")),
- projectEnv: existsSync(join(root, ".env")),
- globalEnv: existsSync(GLOBAL_ENV_PATH),
- booksDir: existsSync(join(root, "books")),
- llmConnected: false,
- bookCount: 0,
- };
- try {
- const books = await state.listBooks();
- checks.bookCount = books.length;
- } catch { /* ignore */ }
- try {
- const currentConfig = await loadCurrentProjectConfig({ requireApiKey: false });
- const client = createLLMClient(currentConfig.llm);
- const { chatCompletion } = await import("@actalk/inkos-core");
- await chatCompletion(client, currentConfig.llm.model, [{ role: "user", content: "ping" }], { maxTokens: 5 });
- checks.llmConnected = true;
- } catch { /* ignore */ }
- return c.json(checks);
- });
- return app;
- }
- // --- Standalone runner ---
- export async function startStudioServer(
- root: string,
- port = 4567,
- options?: { readonly staticDir?: string },
- ): Promise<void> {
- const config = await loadProjectConfig(root, { requireApiKey: false });
- const app = createStudioServer(config, root);
- // Serve frontend static files — single process for API + frontend
- if (options?.staticDir) {
- const { readFile: readFileFs } = await import("node:fs/promises");
- const { join: joinPath } = await import("node:path");
- const { existsSync } = await import("node:fs");
- // Serve static assets (js, css, etc.)
- app.get("/assets/*", async (c) => {
- const filePath = joinPath(options.staticDir!, c.req.path);
- try {
- const content = await readFileFs(filePath);
- const ext = filePath.split(".").pop() ?? "";
- const contentTypes: Record<string, string> = {
- js: "application/javascript",
- css: "text/css",
- svg: "image/svg+xml",
- png: "image/png",
- ico: "image/x-icon",
- json: "application/json",
- };
- return new Response(content, {
- headers: { "Content-Type": contentTypes[ext] ?? "application/octet-stream" },
- });
- } catch {
- return c.notFound();
- }
- });
- // SPA fallback — serve index.html for all non-API routes
- const indexPath = joinPath(options.staticDir!, "index.html");
- if (existsSync(indexPath)) {
- const indexHtml = await readFileFs(indexPath, "utf-8");
- app.get("*", (c) => {
- if (c.req.path.startsWith("/api/v1/")) return c.notFound();
- return c.html(indexHtml);
- });
- }
- }
- console.log(`InkOS Studio running on http://localhost:${port}`);
- serve({ fetch: app.fetch, port });
- }
|