| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572 |
- 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,
- listBookSessions,
- loadBookSession,
- persistBookSession,
- appendBookSessionMessage,
- createAndPersistBookSession,
- renameBookSession,
- deleteBookSession,
- migrateBookSession,
- SessionAlreadyMigratedError,
- runAgentSession,
- buildAgentSystemPrompt,
- resolveServicePreset,
- resolveServiceProviderFamily,
- resolveServiceModelsBaseUrl,
- resolveServiceModel,
- loadSecrets,
- saveSecrets,
- getServiceApiKey,
- listModelsForService,
- getAllEndpoints,
- probeModelsFromUpstream,
- chatCompletion,
- buildExportArtifact,
- GLOBAL_ENV_PATH,
- type ResolvedModel,
- type PipelineConfig,
- type ProjectConfig,
- type LogSink,
- type LogEntry,
- } 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);
- }
- const NON_TEXT_MODEL_ID_PARTS = [
- "image",
- "embedding",
- "embed",
- "rerank",
- "tts",
- "speech",
- "audio",
- "moderation",
- ] as const;
- function isTextChatModelId(modelId: string): boolean {
- const normalized = modelId.trim().toLowerCase();
- if (!normalized) return false;
- return !NON_TEXT_MODEL_ID_PARTS.some((part) => normalized.includes(part));
- }
- function filterTextChatModels<T extends { readonly id: string }>(models: ReadonlyArray<T>): T[] {
- return models.filter((model) => isTextChatModelId(model.id));
- }
- function nonTextModelMessage(modelId: string): string {
- return `模型 ${modelId} 不适合文本聊天/写作。请在模型选择器中改用文本模型,例如 gemini-2.5-flash、gemini-2.5-pro 或对应服务的 chat 模型。`;
- }
- 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 }>();
- // 内存缓存:service -> 模型列表 + 更新时间戳;避免每次 sidebar 挂载时都打真实 LLM /models
- const modelListCache = new Map<string, { models: Array<{ id: string; name: string }>; at: number }>();
- interface ServiceConfigEntry {
- service: string;
- name?: string;
- baseUrl?: string;
- temperature?: 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<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 } : {}),
- ...(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 } : {}),
- ...(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 } : {}),
- ...(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 } : {}),
- ...(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;
- }
- }
- 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<string>();
- 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 }>;
- includeGenericFallbacks?: boolean;
- }): string[] {
- const seen = new Set<string>();
- 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);
- if (args.includeGenericFallbacks === false) return candidates;
- push("gpt-5.4");
- push("gpt-4o");
- push("claude-sonnet-4-6");
- push("MiniMax-M2.7");
- push("kimi-k2.5");
- return candidates;
- }
- function formatServiceProbeError(args: {
- readonly service: string;
- readonly label?: string;
- readonly baseUrl: string;
- readonly model?: string;
- readonly apiFormat?: "chat" | "responses";
- readonly stream?: boolean;
- readonly error: string;
- }): string {
- const rawDetail = args.error
- .replace(/\n\s*\(baseUrl:[\s\S]*?\)$/m, "")
- .trim();
- const upstreamDetail = rawDetail.includes("上游详情:")
- ? rawDetail
- : "";
- const context = [
- `服务商:${args.label ?? args.service}`,
- `测试模型:${args.model ?? "未确定"}`,
- `协议:${args.apiFormat === "responses" ? "Responses" : "Chat / Completions"}${typeof args.stream === "boolean" ? `,${args.stream ? "流式" : "非流式"}` : ""}`,
- `Base URL:${args.baseUrl}`,
- ].join("\n");
- if (args.service === "google") {
- return [
- "Google Gemini 测试连接失败。",
- context,
- "",
- "请优先检查:",
- "1. API Key 是否来自 Google AI Studio 的 Gemini API key,而不是 OAuth、Vertex AI 或其它 Google 服务凭据。",
- "2. 该 key 所属项目是否已启用 Gemini API,并且没有被限制到其它 API、来源或服务。",
- "3. 当前地区/账号是否允许访问 Gemini API。",
- "4. 如果 key 曾经泄露,请在 AI Studio 重新生成后再保存。",
- upstreamDetail ? `\n上游返回:${upstreamDetail}` : "",
- ].filter(Boolean).join("\n");
- }
- if (args.service === "moonshot" || args.service === "kimiCodingPlan") {
- return [
- `${args.label ?? args.service} 测试连接失败。`,
- context,
- "",
- "请优先检查模型是否可用,以及 kimi-k2.x 这类模型是否需要 temperature=1。",
- rawDetail ? `\n上游返回:${rawDetail}` : "",
- ].filter(Boolean).join("\n");
- }
- return [
- `${args.label ?? args.service} 测试连接失败。`,
- context,
- "",
- "请检查 API Key、模型可用性、账号额度,以及协议类型是否匹配该服务商。",
- rawDetail ? `\n上游返回:${rawDetail}` : "",
- ].filter(Boolean).join("\n");
- }
- 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<ServiceProbeResult> {
- const rawConfig = await loadRawConfig(args.root).catch(() => ({} as Record<string, unknown>));
- const llm = (rawConfig.llm as Record<string, unknown> | 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 bank services, probe with the service's own check model first — not the global default.
- const endpoint = getAllEndpoints().find((ep) => ep.id === baseService);
- const preset = resolveServicePreset(baseService);
- const serviceFirstModel =
- endpoint?.checkModel
- ?? preset?.knownModels?.[0]
- ?? endpoint?.models.find((model) => model.enabled !== false)?.id;
- const useEndpointCheckModel = !isCustomServiceId(args.service) && Boolean(endpoint?.checkModel);
- const configService = typeof llm.service === "string" ? llm.service : undefined;
- const configModel = !useEndpointCheckModel && configService === args.service
- ? typeof llm.defaultModel === "string"
- ? llm.defaultModel
- : typeof llm.model === "string"
- ? llm.model
- : undefined
- : undefined;
- const useCustomFallbacks = isCustomServiceId(args.service);
- const modelCandidates = buildModelCandidates({
- preferredModel: args.preferredModel ?? serviceFirstModel,
- configModel,
- envModel: useCustomFallbacks ? envModel : undefined,
- discoveredModels: useEndpointCheckModel ? [] : discoveredModels,
- includeGenericFallbacks: useCustomFallbacks,
- });
- 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
- : preset?.knownModels?.map((id) => ({ id, name: id })) ?? [{ 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 = formatServiceProbeError({
- service: baseService,
- label: endpoint?.label ?? preset?.label,
- baseUrl: args.baseUrl,
- model,
- apiFormat: plan.apiFormat,
- stream: plan.stream,
- error: 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<ProjectConfig> {
- const freshConfig = await loadProjectConfig(root, options);
- cachedConfig = freshConfig;
- return freshConfig;
- }
- async function buildPipelineConfig(
- overrides?: Partial<Pick<PipelineConfig, "externalContext" | "client" | "model">> & {
- readonly currentConfig?: ProjectConfig;
- readonly sessionIdForSSE?: string;
- },
- ): Promise<PipelineConfig> {
- const currentConfig = overrides?.currentConfig ?? await loadCurrentProjectConfig();
- const scopedSseSink: LogSink = overrides?.sessionIdForSSE
- ? {
- write(entry) {
- broadcast("log", {
- sessionId: overrides.sessionIdForSSE,
- level: entry.level,
- tag: entry.tag,
- message: entry.message,
- });
- },
- }
- : sseSink;
- const logger = createLogger({ tag: "studio", sinks: [scopedSseSink, 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", {
- ...(overrides?.sessionIdForSSE ? { sessionId: overrides.sessionIdForSSE } : {}),
- 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 endpoints = getAllEndpoints().filter((ep) => ep.id !== "custom");
- // Fast: only check connection status from secrets, no external API calls.
- const services = endpoints.map((ep) => ({
- service: ep.id,
- label: ep.label,
- group: ep.group,
- connected: Boolean(secrets.services[ep.id]?.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",
- group: undefined,
- 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; service?: string }>();
- 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);
- }
- 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,
- });
- // B12: 升级响应 shape 为 { probe, chat, ... },同时保留老字段供 UI 过渡期兼容
- const probeStatus = {
- ok: probe.ok,
- models: probe.models?.length ?? 0,
- ...(probe.ok ? {} : { error: probe.error ?? "连接失败" }),
- };
- if (!probe.ok) {
- return c.json({
- ok: false,
- error: probe.error ?? "连接失败",
- probe: probeStatus,
- chat: null,
- }, 400);
- }
- return c.json({
- ok: true,
- modelCount: probe.models.length,
- models: probe.models,
- selectedModel: probe.selectedModel,
- detected: {
- apiFormat: probe.apiFormat,
- stream: probe.stream,
- baseUrl: probe.baseUrl,
- modelsSource: probe.modelsSource,
- },
- // B12 新字段:两步验证状态
- probe: probeStatus,
- chat: null, // probeServiceCapabilities 本身只做 probe,chat hello 在 Studio 的 follow-up 调用里单独触发
- });
- });
- 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/models", async (c) => {
- const secrets = await loadSecrets(root);
- const endpoints = getAllEndpoints()
- .filter((ep) => ep.id !== "custom" && Boolean(secrets.services[ep.id]?.apiKey));
- const groups = endpoints.map((ep) => ({
- service: ep.id,
- label: ep.label,
- models: ep.models
- .filter((m) => m.enabled !== false)
- .filter((m) => isTextChatModelId(m.id))
- .map((m) => ({
- id: m.id,
- name: m.id,
- ...(typeof m.maxOutput === "number" ? { maxOutput: m.maxOutput } : {}),
- ...(m.contextWindowTokens > 0 ? { contextWindow: m.contextWindowTokens } : {}),
- })),
- }));
- return c.json({ groups });
- });
- app.get("/api/v1/services/models/custom", async (c) => {
- const secrets = await loadSecrets(root);
- let config: Record<string, unknown> = {};
- try {
- config = await loadRawConfig(root);
- } catch {
- // no config file
- }
- const customs = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services)
- .filter((s) => s.service === "custom")
- .map((s) => ({
- id: `custom:${s.name ?? "Custom"}`,
- baseUrl: s.baseUrl ?? "",
- label: s.name ?? "Custom",
- }))
- .filter((s) => s.baseUrl && Boolean(secrets.services[s.id]?.apiKey));
- const groups = await Promise.all(customs.map(async (s) => ({
- service: s.id,
- label: s.label,
- models: filterTextChatModels(
- await probeModelsFromUpstream(s.baseUrl, secrets.services[s.id].apiKey, 10_000),
- ),
- })));
- return c.json({ groups });
- });
- app.get("/api/v1/services/:service/models", async (c) => {
- const service = c.req.param("service");
- const refresh = c.req.query("refresh") === "1";
- const apiKey = c.req.query("apiKey") || await getServiceApiKey(root, service);
- // No key = no models
- if (!apiKey) return c.json({ models: [] });
- const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
- // Cache by service + resolved baseUrl + apiKey fingerprint; valid for 10 min unless ?refresh=1
- const cacheKey = `${service}::${resolvedBaseUrl ?? ""}::${apiKey.slice(-8)}`;
- if (!refresh) {
- const cached = modelListCache.get(cacheKey);
- if (cached && Date.now() - cached.at < 10 * 60 * 1000) {
- return c.json({ models: cached.models });
- }
- }
- // B13: 走 listModelsForService 走 live probe + bank 交叉,返回带元数据的 models
- const enriched = await listModelsForService(
- isCustomServiceId(service) ? "custom" : service,
- apiKey,
- isCustomServiceId(service) ? resolvedBaseUrl ?? undefined : undefined,
- );
- const models = filterTextChatModels(enriched).map((m) => ({
- id: m.id,
- name: m.name,
- ...(m.maxOutput !== undefined ? { maxOutput: m.maxOutput } : {}),
- ...(m.contextWindow > 0 ? { contextWindow: m.contextWindow } : {}),
- }));
- modelListCache.set(cacheKey, { models, at: Date.now() });
- 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,
- });
- });
- // --- 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.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 });
- });
- 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; sessionId?: string }>().catch(() => ({}));
- const bookId = (body as { bookId?: string | null }).bookId ?? null;
- const sessionId = (body as { sessionId?: string }).sessionId;
- // sessionId 只允许 timestamp-random 格式;防止注入任意文件名
- const safeSessionId = sessionId && /^[0-9]+-[a-z0-9]+$/.test(sessionId) ? sessionId : undefined;
- const session = await createAndPersistBookSession(root, bookId, safeSessionId);
- return c.json({ session });
- });
- app.put("/api/v1/sessions/:sessionId", async (c) => {
- const sessionId = c.req.param("sessionId");
- const body = await c.req.json<{ title?: string }>().catch(() => ({}) as { title?: string });
- const title = body.title?.trim();
- if (!title) {
- throw new ApiError(400, "INVALID_SESSION_TITLE", "Session title is required");
- }
- const session = await renameBookSession(root, sessionId, title);
- if (!session) {
- return c.json({ error: "Session not found" }, 404);
- }
- return c.json({ session });
- });
- app.delete("/api/v1/sessions/:sessionId", async (c) => {
- await deleteBookSession(root, c.req.param("sessionId"));
- return c.json({ ok: true });
- });
- 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);
- }
- if (!sessionId?.trim()) {
- throw new ApiError(400, "SESSION_ID_REQUIRED", "sessionId is required");
- }
- if (reqModel && !isTextChatModelId(reqModel)) {
- const message = nonTextModelMessage(reqModel);
- return c.json({ error: message, response: message }, 400);
- }
- broadcast("agent:start", { instruction, activeBookId, sessionId });
- try {
- // Load config + create LLM client (pipeline created after model resolution)
- const config = await loadCurrentProjectConfig({ requireApiKey: false });
- const client = createLLMClient(config.llm);
- const loadedBookSession = await loadBookSession(root, sessionId);
- if (!loadedBookSession) {
- throw new ApiError(404, "SESSION_NOT_FOUND", `Session not found: ${sessionId}`);
- }
- let bookSession = loadedBookSession;
- const streamSessionId = loadedBookSession.sessionId;
- // 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 && isTextChatModelId(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);
- const textModels = filterTextChatModels(models);
- if (textModels.length > 0) {
- const configuredEntry = await resolveConfiguredServiceEntry(root, svcName);
- const resolved = await resolveServiceModel(
- svcName,
- textModels[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,
- sessionIdForSSE: bookSession.sessionId,
- }));
- // 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", { sessionId: streamSessionId, text: ame.delta });
- } else if (ame.type === "thinking_delta") {
- broadcast("thinking:delta", { sessionId: streamSessionId, text: (ame as any).delta });
- } else if (ame.type === "thinking_start") {
- broadcast("thinking:start", { sessionId: streamSessionId });
- } else if (ame.type === "thinking_end") {
- broadcast("thinking:end", { sessionId: streamSessionId });
- }
- }
- 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", {
- sessionId: streamSessionId,
- id: event.toolCallId,
- tool: event.toolName,
- args,
- stages,
- });
- }
- if (event.type === "tool_execution_update") {
- broadcast("tool:update", {
- sessionId: streamSessionId,
- 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", {
- sessionId: streamSessionId,
- 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(),
- });
- // 第一条用户消息就是 session 的标题:如果 title 还是 null,用消息内容(单行、≤20字)写入。
- // 后续消息不覆盖;用户手动改名通过 renameBookSession 覆盖。
- if (bookSession.title === null) {
- const oneLine = instruction.trim().replace(/\s+/g, " ");
- const title = oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
- if (title) {
- bookSession = { ...bookSession, title };
- broadcast("session:title", { sessionId: bookSession.sessionId, title });
- }
- }
- 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, sessionId: bookSession.sessionId });
- // 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) {
- try {
- const migratedSession = await migrateBookSession(root, bookSession.sessionId, latestBook);
- if (migratedSession) {
- bookSession = migratedSession;
- }
- } catch (e) {
- if (!(e instanceof SessionAlreadyMigratedError)) {
- throw e;
- }
- }
- broadcast("book:created", { bookId: latestBook, sessionId: bookSession.sessionId });
- }
- }
- return c.json({
- response: result.responseText,
- session: {
- sessionId: bookSession.sessionId,
- ...(bookSession.bookId ? { activeBookId: bookSession.bookId } : {}),
- },
- });
- } catch (e) {
- if (e instanceof ApiError) {
- throw e;
- }
- if (e instanceof SessionAlreadyMigratedError) {
- const migratedMessage = e instanceof Error ? e.message : String(e);
- throw new ApiError(409, "SESSION_ALREADY_MIGRATED", migratedMessage);
- }
- const msg = e instanceof Error ? e.message : String(e);
- broadcast("agent:error", { instruction, activeBookId, sessionId, 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<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 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<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 });
- }
|