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, resolveServiceProviderFamily, resolveServiceModelsBaseUrl, resolveServiceModel, loadSecrets, saveSecrets, getServiceApiKey, listModelsForService, chatCompletion, buildExportArtifact, 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 = { writer: [ "准备章节输入", "撰写章节草稿", "落盘最终章节", "生成最终真相文件", "校验真相文件变更", "同步记忆索引", "更新章节索引与快照", ], architect: [ "生成基础设定", "保存书籍配置", "写入基础设定文件", "初始化控制文档", "创建初始快照", ], reviser: [ "加载修订上下文", "修订章节", "落盘修订结果", "更新索引与快照", ], auditor: ["审计章节"], }; const AGENT_LABELS: Record = { architect: "建书", writer: "写作", auditor: "审计", reviser: "修订", exporter: "导出", }; const TOOL_LABELS: Record = { 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; 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; 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; 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(); const bookCreateStatus = new Map(); 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; } interface ServiceProbeResult { ok: boolean; models: Array<{ id: string; name: string }>; selectedModel?: string; apiFormat?: "chat" | "responses"; stream?: boolean; baseUrl?: string; modelsSource?: "api" | "fallback"; error?: string; } 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): 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 => 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) .filter(([, value]) => value && typeof value === "object") .map(([serviceId, value]) => normalizeServiceEntry(serviceId, value as Record)); } 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> { const configPath = join(root, "inkos.json"); const raw = await readFile(configPath, "utf-8"); return JSON.parse(raw) as Record; } async function saveRawConfig(root: string, config: Record): Promise { await writeFile(join(root, "inkos.json"), JSON.stringify(config, null, 2), "utf-8"); } async function readEnvConfigSummary(path: string): Promise { try { const raw = await readFile(path, "utf-8"); const values = new Map(); 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 { 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 { 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 | undefined)?.services); const matched = services.find((entry) => serviceConfigKey(entry) === serviceId); return matched?.baseUrl; } catch { return undefined; } } async function resolveConfiguredServiceEntry(root: string, serviceId: string): Promise { try { const config = await loadRawConfig(root); const services = normalizeServiceConfig((config.llm as Record | undefined)?.services); return services.find((entry) => serviceConfigKey(entry) === serviceId); } catch { return undefined; } } function buildProbePlans( preferredApiFormat: "chat" | "responses" | undefined, preferredStream: boolean | undefined, ): Array<{ apiFormat: "chat" | "responses"; stream: boolean }> { const candidates: Array<{ apiFormat: "chat" | "responses"; stream: boolean }> = []; const seen = new Set(); const push = (apiFormat: "chat" | "responses", stream: boolean) => { const key = `${apiFormat}:${stream ? "1" : "0"}`; if (seen.has(key)) return; seen.add(key); candidates.push({ apiFormat, stream }); }; if (preferredApiFormat) { push(preferredApiFormat, preferredStream ?? false); push(preferredApiFormat, !(preferredStream ?? false)); } const alternate = preferredApiFormat === "responses" ? "chat" : "responses"; push(alternate, false); push(alternate, true); push("chat", false); push("chat", true); push("responses", false); push("responses", true); return candidates; } function buildModelCandidates(args: { preferredModel?: string; configModel?: string; envModel?: string | null; discoveredModels: Array<{ id: string; name: string }>; }): string[] { const seen = new Set(); const candidates: string[] = []; const push = (value: string | null | undefined) => { if (!value || value.trim().length === 0) return; const id = value.trim(); if (seen.has(id)) return; seen.add(id); candidates.push(id); }; push(args.preferredModel); push(args.configModel); push(args.envModel ?? undefined); for (const model of args.discoveredModels) push(model.id); push("gpt-5.4"); push("gpt-4o"); push("claude-sonnet-4-6"); push("MiniMax-M2.7"); push("kimi-k2.5"); return candidates; } async function fetchModelsFromServiceBaseUrl( serviceId: string, baseUrl: string, apiKey: string, ): Promise<{ models: Array<{ id: string; name: string }>; error?: string; authFailed?: boolean }> { const modelsBaseUrl = isCustomServiceId(serviceId) ? baseUrl : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl; const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models"; try { const res = await fetch(modelsUrl, { headers: { Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(10_000), }); if (!res.ok) { const body = await res.text().catch(() => ""); return { models: [], error: `服务商返回 ${res.status}: ${body.slice(0, 200)}`, authFailed: res.status === 401 || res.status === 403, }; } const json = await res.json() as { data?: Array<{ id: string }> }; return { models: (json.data ?? []).map((m) => ({ id: m.id, name: m.id })), }; } catch (error) { return { models: [], error: error instanceof Error ? error.message : String(error), }; } } async function probeServiceCapabilities(args: { root: string; service: string; apiKey: string; baseUrl: string; preferredApiFormat?: "chat" | "responses"; preferredStream?: boolean; preferredModel?: string; }): Promise { const rawConfig = await loadRawConfig(args.root).catch(() => ({} as Record)); const llm = (rawConfig.llm as Record | undefined) ?? {}; const envConfig = await readEnvConfigStatus(args.root); const envModel = envConfig.effectiveSource === "project" ? envConfig.project.model : envConfig.effectiveSource === "global" ? envConfig.global.model : null; const baseService = isCustomServiceId(args.service) ? "custom" : args.service; const modelsResponse = await fetchModelsFromServiceBaseUrl(baseService, args.baseUrl, args.apiKey); if (modelsResponse.authFailed) { return { ok: false, models: [], error: modelsResponse.error ?? "API Key 无效或无权访问模型列表。", }; } const discoveredModels = modelsResponse.models; // For services with knownModels, use their first model as top candidate — not the global default const preset = resolveServicePreset(baseService); const serviceFirstModel = preset?.knownModels?.[0]; const modelCandidates = buildModelCandidates({ preferredModel: args.preferredModel ?? serviceFirstModel, configModel: typeof llm.defaultModel === "string" ? llm.defaultModel : typeof llm.model === "string" ? llm.model : undefined, envModel, discoveredModels, }); if (modelCandidates.length === 0) { return { ok: false, models: [], error: "无法自动确定模型,请先填写可用模型或提供支持 /models 的服务端点。", }; } let lastError = modelsResponse.error ?? "自动探测失败"; for (const model of modelCandidates) { for (const plan of buildProbePlans(args.preferredApiFormat, args.preferredStream)) { const client = createLLMClient({ provider: resolveServiceProviderFamily(baseService) ?? "openai", service: baseService, configSource: "studio", baseUrl: args.baseUrl, apiKey: args.apiKey.trim(), model, temperature: 0.7, maxTokens: 2048, thinkingBudget: 0, apiFormat: plan.apiFormat, stream: plan.stream, } as ProjectConfig["llm"]); try { await chatCompletion(client, model, [{ role: "user", content: "ping" }], { maxTokens: 2048 }); const models = discoveredModels.length > 0 ? discoveredModels : [{ id: model, name: model }]; return { ok: true, models, selectedModel: model, apiFormat: plan.apiFormat, stream: plan.stream, baseUrl: args.baseUrl, modelsSource: discoveredModels.length > 0 ? "api" : "fallback", }; } catch (error) { lastError = error instanceof Error ? error.message : String(error); } } } return { ok: false, models: discoveredModels, error: lastError, }; } // --- 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 { const freshConfig = await loadProjectConfig(root, options); cachedConfig = freshConfig; return freshConfig; } async function buildPipelineConfig( overrides?: Partial> & { readonly currentConfig?: ProjectConfig; }, ): Promise { 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>; }) => { 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 | 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 | 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; service?: string }>(); const config = await loadRawConfig(root); config.llm = config.llm ?? {}; const llm = config.llm as Record; 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); } if (body.service !== undefined) { llm.service = body.service; } 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); } const probe = await probeServiceCapabilities({ root, service, apiKey: apiKey.trim(), baseUrl: resolvedBaseUrl, preferredApiFormat: apiFormat, preferredStream: stream, }); if (!probe.ok) { return c.json({ ok: false, error: probe.error ?? "连接失败" }, 400); } return c.json({ ok: true, modelCount: probe.models.length, models: probe.models.slice(0, 50), selectedModel: probe.selectedModel, detected: { apiFormat: probe.apiFormat, stream: probe.stream, baseUrl: probe.baseUrl, modelsSource: probe.modelsSource, }, }); }); 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/secret", async (c) => { const service = c.req.param("service"); const secrets = await loadSecrets(root); return c.json({ apiKey: secrets.services[service]?.apiKey ?? "", }); }); 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 if (!apiKey) return c.json({ models: [] }); // Fast path: services with knownModels return immediately const preset = resolveServicePreset(isCustomServiceId(service) ? "custom" : service); if (preset?.knownModels && preset.knownModels.length > 0) { return c.json({ models: preset.knownModels.map((id) => ({ id, name: id })), }); } // Simple /models API call + fallback to pi-ai built-in list (no slow probe) const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service); if (!resolvedBaseUrl) return c.json({ models: [] }); const modelsBase = preset?.modelsBaseUrl ?? resolvedBaseUrl; let models: Array<{ id: string; name: string }> = []; try { const modelsUrl = modelsBase.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 */ } if (models.length === 0) { const builtIn = await listModelsForService(service, apiKey); models = builtIn.map((m) => ({ id: m.id, name: m.name })); } 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>(); 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; 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 | 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 ?? "", ...(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 ?? "", ...(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 }); // If a sub_agent created a new book during this session, broadcast book:created // so the sidebar refreshes. if (!activeBookId && collectedToolExecs.some((t) => t.agent === "architect" && t.status === "completed")) { const books = await state.listBooks(); const latestBook = books.at(-1); if (latestBook) { broadcast("book:created", { bookId: latestBook }); } } 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"; try { const artifact = await buildExportArtifact(state, id, { format: format as "txt" | "md" | "epub", approvedOnly, }); const responseBody = typeof artifact.payload === "string" ? artifact.payload : new Uint8Array(artifact.payload); return new Response(responseBody, { headers: { "Content-Type": artifact.contentType, "Content-Disposition": `attachment; filename="${artifact.fileName}"`, }, }); } 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 }>(); 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; 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 service = currentConfig.llm.service ?? currentConfig.llm.provider; const probe = await probeServiceCapabilities({ root, service, apiKey: currentConfig.llm.apiKey, baseUrl: currentConfig.llm.baseUrl, preferredApiFormat: currentConfig.llm.apiFormat, preferredStream: currentConfig.llm.stream, preferredModel: currentConfig.llm.model, }); checks.llmConnected = probe.ok; } catch { /* ignore */ } return c.json(checks); }); return app; } // --- Standalone runner --- export async function startStudioServer( root: string, port = 4567, options?: { readonly staticDir?: string }, ): Promise { 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 = { 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 }); }