| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671 |
- import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
- import { dirname, join } from "node:path";
- import type {
- InteractionEvent,
- Logger,
- PipelineRunner,
- StateManager,
- ReviseMode,
- LLMClient,
- BookConfig,
- Platform,
- ToolDefinition,
- } from "../index.js";
- import { chatCompletion, chatWithTools } from "../index.js";
- import { executeEditTransaction } from "./edit-controller.js";
- import type { InteractionRuntimeTools } from "./runtime.js";
- import type { BookCreationDraft } from "./session.js";
- import { writeExportArtifact } from "./export-artifact.js";
- type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft"> & {
- readonly initBook?: (
- book: BookConfig,
- options?: {
- readonly externalContext?: string;
- readonly authorIntent?: string;
- readonly currentFocus?: string;
- },
- ) => Promise<void>;
- };
- type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
- type InstrumentablePipelineLike = PipelineLike & {
- readonly config?: {
- logger?: Logger;
- client?: LLMClient;
- model?: string;
- };
- };
- function normalizePlatform(platform?: string): Platform {
- switch (platform) {
- case "tomato":
- case "feilu":
- case "qidian":
- return platform;
- default:
- return "other";
- }
- }
- function deriveBookId(title: string): string {
- return title
- .toLowerCase()
- .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
- .replace(/-+/g, "-")
- .slice(0, 30);
- }
- function buildBookConfig(input: {
- readonly title: string;
- readonly genre?: string;
- readonly platform?: string;
- readonly language?: "zh" | "en";
- readonly chapterWordCount?: number;
- readonly targetChapters?: number;
- }): BookConfig {
- const now = new Date().toISOString();
- return {
- id: deriveBookId(input.title),
- title: input.title,
- platform: normalizePlatform(input.platform),
- genre: input.genre ?? "other",
- status: "outlining",
- targetChapters: input.targetChapters ?? 200,
- chapterWordCount: input.chapterWordCount ?? 3000,
- ...(input.language ? { language: input.language } : {}),
- createdAt: now,
- updatedAt: now,
- };
- }
- function buildCreationExternalContext(input: {
- readonly blurb?: string;
- readonly worldPremise?: string;
- readonly settingNotes?: string;
- readonly protagonist?: string;
- readonly supportingCast?: string;
- readonly conflictCore?: string;
- readonly volumeOutline?: string;
- readonly constraints?: string;
- }): string | undefined {
- const sections = [
- input.worldPremise ? `## 世界观与核心设定\n${input.worldPremise}` : undefined,
- input.settingNotes ? `## 补充设定\n${input.settingNotes}` : undefined,
- input.protagonist ? `## 主角设定\n${input.protagonist}` : undefined,
- input.supportingCast ? `## 关键角色与势力\n${input.supportingCast}` : undefined,
- input.conflictCore ? `## 核心冲突\n${input.conflictCore}` : undefined,
- input.volumeOutline ? `## 卷纲方向\n${input.volumeOutline}` : undefined,
- input.blurb ? `## 简介卖点\n${input.blurb}` : undefined,
- input.constraints ? `## 创作约束\n${input.constraints}` : undefined,
- ].filter((section): section is string => Boolean(section?.trim()));
- if (sections.length === 0) {
- return undefined;
- }
- return sections.join("\n\n");
- }
- export function buildChapterFileLookup(files: ReadonlyArray<string>): ReadonlyMap<number, string> {
- const lookup = new Map<number, string>();
- for (const file of files) {
- if (!file.endsWith(".md") || !/^\d{4}/.test(file)) {
- continue;
- }
- const chapterNumber = parseInt(file.slice(0, 4), 10);
- if (!lookup.has(chapterNumber)) {
- lookup.set(chapterNumber, file);
- }
- }
- return lookup;
- }
- async function exportBookToPath(state: StateLike, bookId: string, options: {
- readonly format?: "txt" | "md" | "epub";
- readonly approvedOnly?: boolean;
- readonly outputPath?: string;
- }) {
- return writeExportArtifact(state, bookId, options);
- }
- function mapStageMessageToStatus(message: string): InteractionEvent["status"] | undefined {
- const lower = message.trim().toLowerCase();
- if (
- lower.includes("planning next chapter")
- || lower.includes("generating foundation")
- || lower.includes("reviewing foundation")
- || lower.includes("preparing chapter inputs")
- || message.includes("规划下一章意图")
- || message.includes("生成基础设定")
- || message.includes("审核基础设定")
- || message.includes("准备章节输入")
- ) {
- return "planning";
- }
- if (
- lower.includes("composing chapter runtime context")
- || message.includes("组装章节运行时上下文")
- ) {
- return "composing";
- }
- if (
- lower.includes("writing chapter draft")
- || message.includes("撰写章节草稿")
- ) {
- return "writing";
- }
- if (
- lower.includes("auditing draft")
- || message.includes("审计草稿")
- ) {
- return "assessing";
- }
- if (
- lower.includes("fixing")
- || lower.includes("revising chapter")
- || lower.includes("rewrite")
- || lower.includes("repair")
- || message.includes("自动修复")
- || message.includes("整章改写")
- || message.includes("修订第")
- ) {
- return "repairing";
- }
- if (
- lower.includes("persist")
- || lower.includes("saving")
- || lower.includes("snapshot")
- || lower.includes("rebuilding final truth files")
- || lower.includes("validating truth file updates")
- || lower.includes("syncing memory indexes")
- || message.includes("落盘")
- || message.includes("保存")
- || message.includes("快照")
- || message.includes("校验真相文件变更")
- || message.includes("生成最终真相文件")
- || message.includes("同步记忆索引")
- ) {
- return "persisting";
- }
- return undefined;
- }
- function extractStageDetail(message: string): string | undefined {
- if (message.startsWith("Stage: ")) {
- return message.slice("Stage: ".length).trim();
- }
- if (message.startsWith("阶段:")) {
- return message.slice("阶段:".length).trim();
- }
- return undefined;
- }
- function createInteractionLogger(
- original: Logger | undefined,
- events: InteractionEvent[],
- bookId: string,
- ): Logger {
- const emit = (level: "debug" | "info" | "warn" | "error", message: string): void => {
- const stageDetail = extractStageDetail(message);
- const stageStatus = stageDetail ? mapStageMessageToStatus(stageDetail) : undefined;
- if (stageDetail && stageStatus) {
- events.push({
- kind: "stage.changed",
- timestamp: Date.now(),
- status: stageStatus,
- bookId,
- detail: stageDetail,
- });
- return;
- }
- if (level === "warn") {
- events.push({
- kind: "task.warning",
- timestamp: Date.now(),
- status: "blocked",
- bookId,
- detail: message,
- });
- return;
- }
- if (level === "error") {
- events.push({
- kind: "task.failed",
- timestamp: Date.now(),
- status: "failed",
- bookId,
- detail: message,
- });
- }
- };
- const wrap = (base: Logger | undefined): Logger => ({
- debug: (msg, ctx) => {
- emit("debug", msg);
- base?.debug(msg, ctx);
- },
- info: (msg, ctx) => {
- emit("info", msg);
- base?.info(msg, ctx);
- },
- warn: (msg, ctx) => {
- emit("warn", msg);
- base?.warn(msg, ctx);
- },
- error: (msg, ctx) => {
- emit("error", msg);
- base?.error(msg, ctx);
- },
- child: (tag, extraCtx) => wrap(base?.child(tag, extraCtx)),
- });
- return wrap(original);
- }
- async function withPipelineInteractionTelemetry<T extends { chapterNumber?: number }>(
- pipeline: InstrumentablePipelineLike,
- bookId: string,
- executor: () => Promise<T>,
- ): Promise<T & {
- __interaction: {
- events: ReadonlyArray<InteractionEvent>;
- activeChapterNumber?: number;
- };
- }> {
- const events: InteractionEvent[] = [];
- const originalLogger = pipeline.config?.logger;
- if (pipeline.config) {
- pipeline.config.logger = createInteractionLogger(originalLogger, events, bookId);
- }
- try {
- const result = await executor();
- return {
- ...result,
- __interaction: {
- events,
- ...(typeof result.chapterNumber === "number"
- ? { activeChapterNumber: result.chapterNumber }
- : {}),
- },
- };
- } finally {
- if (pipeline.config) {
- pipeline.config.logger = originalLogger;
- }
- }
- }
- const CREATE_BOOK_TOOL: ToolDefinition = {
- name: "create_book",
- description: "根据用户描述生成建书参数。系统会将参数渲染为可编辑表单,用户确认后建书。",
- parameters: {
- type: "object",
- properties: {
- title: { type: "string", description: "书名" },
- genre: { type: "string", description: "题材标识,如 xuanhuan, urban, romance, scifi, mystery" },
- platform: { type: "string", enum: ["tomato", "qidian", "feilu", "other"], description: "发布平台" },
- targetChapters: { type: "number", description: "目标章数,默认 200" },
- chapterWordCount: { type: "number", description: "每章字数,默认 3000" },
- language: { type: "string", enum: ["zh", "en"], description: "写作语言,默认 zh" },
- brief: { type: "string", description: "创意简述,会传给 Architect 智能体生成完整的世界观、主角、冲突等 foundation 文件。把用户提到的所有创意要素都写进这里。" },
- },
- required: ["title", "genre", "platform", "brief"],
- },
- };
- const BOOK_DRAFT_SYSTEM_PROMPT = [
- "你是 InkOS 的建书助手。用户会描述想写的书,你需要调用 create_book 工具来生成建书参数。",
- "",
- "规则:",
- "1. 从用户描述中推断所有字段,大胆预填合理默认值。",
- "2. brief 字段要详细——它会传给 Architect 智能体生成完整的世界观、主角、冲突等 foundation 文件。把用户提到的所有创意要素都写进 brief。",
- "3. 如果用户后续要求修改某些字段,重新调用 create_book 工具,只更新被提到的字段,其余保持不变。",
- "4. 不要只回复文字讨论——必须调用 create_book 工具输出结构化参数。",
- ].join("\n");
- /** Map directive field keys to BookCreationDraft property names. */
- function applyFieldsToDraft(
- existing: BookCreationDraft | undefined,
- fields: Readonly<Record<string, string>>,
- concept: string,
- ): BookCreationDraft {
- const draft: BookCreationDraft = {
- concept,
- missingFields: [],
- readyToCreate: false,
- ...(existing ?? {}),
- };
- for (const [key, value] of Object.entries(fields)) {
- if (!value) continue;
- switch (key) {
- case "title":
- draft.title = value;
- break;
- case "genre":
- draft.genre = value;
- break;
- case "platform":
- draft.platform = value;
- break;
- case "language":
- if (value === "zh" || value === "en") draft.language = value;
- break;
- case "targetChapters": {
- const n = parseInt(value, 10);
- if (!Number.isNaN(n) && n > 0) draft.targetChapters = n;
- break;
- }
- case "chapterWordCount":
- case "chapterLength": {
- const n = parseInt(value, 10);
- if (!Number.isNaN(n) && n > 0) draft.chapterWordCount = n;
- break;
- }
- case "blurb":
- draft.blurb = value;
- break;
- case "worldPremise":
- draft.worldPremise = value;
- break;
- case "settingNotes":
- draft.settingNotes = value;
- break;
- case "protagonist":
- draft.protagonist = value;
- break;
- case "supportingCast":
- draft.supportingCast = value;
- break;
- case "conflictCore":
- draft.conflictCore = value;
- break;
- case "volumeOutline":
- draft.volumeOutline = value;
- break;
- case "constraints":
- draft.constraints = value;
- break;
- case "authorIntent":
- draft.authorIntent = value;
- break;
- case "currentFocus":
- draft.currentFocus = value;
- break;
- // Unknown keys are silently ignored — the LLM may emit
- // application-level keys we don't map to the draft struct.
- }
- }
- return draft;
- }
- function formatDraftForUserMessage(
- existingDraft: BookCreationDraft | undefined,
- userMessage: string,
- ): string {
- const parts: string[] = [];
- if (existingDraft) {
- parts.push("## 当前草案状态");
- const entries = Object.entries(existingDraft).filter(
- ([, v]) => v !== undefined && v !== "" && !(Array.isArray(v) && v.length === 0),
- );
- for (const [key, value] of entries) {
- parts.push(`- **${key}**: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`);
- }
- parts.push("");
- }
- parts.push("## 用户输入");
- parts.push(userMessage);
- return parts.join("\n");
- }
- export function createInteractionToolsFromDeps(
- pipeline: PipelineLike,
- state: StateLike,
- hooks?: {
- readonly onChatTextDelta?: (text: string) => void;
- readonly onDraftTextDelta?: (text: string) => void;
- readonly onDraftRawDelta?: (text: string) => void;
- readonly getChatRequestOptions?: () => {
- readonly temperature?: number;
- readonly maxTokens?: number;
- };
- },
- ): InteractionRuntimeTools {
- const instrumentedPipeline = pipeline as InstrumentablePipelineLike;
- return {
- listBooks: () => state.listBooks(),
- developBookDraft: async (input, existingDraft) => {
- const concept = existingDraft?.concept ?? input;
- if (!instrumentedPipeline.config?.client || !instrumentedPipeline.config?.model) {
- // Fallback: no LLM configured
- return {
- __interaction: {
- responseText: "请先配置 LLM 模型,然后再创建书籍。",
- details: {
- creationDraft: {
- concept,
- missingFields: ["title", "genre", "targetChapters"],
- readyToCreate: false,
- },
- },
- },
- };
- }
- // Build messages - include existing draft context if present
- const userContent = existingDraft
- ? `当前草案参数:${JSON.stringify(existingDraft, null, 2)}\n\n用户输入:${input}`
- : input;
- const result = await chatWithTools(
- instrumentedPipeline.config.client,
- instrumentedPipeline.config.model,
- [
- { role: "system", content: BOOK_DRAFT_SYSTEM_PROMPT },
- { role: "user", content: userContent },
- ],
- [CREATE_BOOK_TOOL],
- { temperature: 0.4 },
- );
- // Extract tool call if present
- const toolCall = result.toolCalls[0];
- let parsedArgs: Record<string, unknown> = {};
- if (toolCall) {
- try {
- parsedArgs = JSON.parse(toolCall.arguments);
- } catch {
- // If parsing fails, use empty args
- }
- }
- // Build a draft from tool call arguments
- const draft: BookCreationDraft = {
- concept,
- title: (parsedArgs.title as string) ?? existingDraft?.title,
- genre: (parsedArgs.genre as string) ?? existingDraft?.genre,
- platform: (parsedArgs.platform as string) ?? existingDraft?.platform,
- language: (parsedArgs.language as "zh" | "en") ?? existingDraft?.language,
- targetChapters: (parsedArgs.targetChapters as number) ?? existingDraft?.targetChapters,
- chapterWordCount: (parsedArgs.chapterWordCount as number) ?? existingDraft?.chapterWordCount,
- blurb: (parsedArgs.brief as string) ?? existingDraft?.blurb,
- missingFields: [],
- readyToCreate: Boolean(parsedArgs.title && parsedArgs.genre && parsedArgs.platform),
- };
- return {
- __interaction: {
- responseText: result.content || "已生成建书参数,请确认或修改。",
- details: {
- creationDraft: draft,
- toolCall: toolCall ? { name: toolCall.name, arguments: parsedArgs } : undefined,
- },
- },
- };
- },
- createBook: async (input) => {
- const book = buildBookConfig(input);
- if (!pipeline.initBook) {
- throw new Error("Pipeline does not support shared book creation.");
- }
- await pipeline.initBook(book, {
- externalContext: buildCreationExternalContext(input),
- authorIntent: input.authorIntent,
- currentFocus: input.currentFocus,
- });
- return {
- bookId: book.id,
- title: book.title,
- __interaction: {
- responseText: `Created ${book.title} (${book.id}).`,
- details: {
- bookId: book.id,
- title: book.title,
- },
- },
- };
- },
- exportBook: async (bookId, options) => {
- const result = await exportBookToPath(state, bookId, options);
- return {
- ...result,
- __interaction: {
- responseText: `Exported ${bookId} to ${result.outputPath} (${result.chaptersExported} chapters).`,
- details: {
- outputPath: result.outputPath,
- chaptersExported: result.chaptersExported,
- totalWords: result.totalWords,
- format: result.format,
- },
- },
- };
- },
- chat: async (input, options) => {
- const bookLabel = options.bookId ?? "none";
- const chatRequestOptions = hooks?.getChatRequestOptions?.() ?? {};
- let response: Awaited<ReturnType<typeof chatCompletion>> | undefined;
- if (instrumentedPipeline.config?.client && instrumentedPipeline.config?.model) {
- try {
- response = await chatCompletion(
- instrumentedPipeline.config.client,
- instrumentedPipeline.config.model,
- [
- {
- role: "system",
- content: [
- "You are InkOS inside the terminal workbench.",
- "Respond conversationally and briefly.",
- "If there is no active book, help the user decide what to write next.",
- "If there is an active book, keep the answer grounded in that book context.",
- ].join(" "),
- },
- {
- role: "user",
- content: `activeBook=${bookLabel}\nautomationMode=${options.automationMode}\nmessage=${input}`,
- },
- ],
- {
- temperature: chatRequestOptions.temperature ?? 0.4,
- ...(chatRequestOptions.maxTokens !== undefined && { maxTokens: chatRequestOptions.maxTokens }),
- onTextDelta: hooks?.onChatTextDelta,
- },
- );
- } catch (err) {
- // Thinking models (e.g. kimi-k2.5) may return empty content for simple inputs.
- // Only swallow empty-content errors; re-throw everything else (network, auth, etc.)
- const msg = err instanceof Error ? err.message : "";
- if (!msg.includes("empty") && !msg.includes("content")) {
- throw err;
- }
- }
- }
- return {
- __interaction: {
- responseText: response?.content?.trim()
- || (options.bookId
- ? `I’m here. Active book is ${options.bookId}.`
- : "I’m here. No active book yet."),
- },
- };
- },
- writeNextChapter: (bookId) => withPipelineInteractionTelemetry(
- instrumentedPipeline,
- bookId,
- () => pipeline.writeNextChapter(bookId),
- ),
- reviseDraft: (bookId, chapterNumber, mode) => withPipelineInteractionTelemetry(
- instrumentedPipeline,
- bookId,
- () => pipeline.reviseDraft(bookId, chapterNumber, mode as ReviseMode),
- ),
- patchChapterText: async (bookId, chapterNumber, targetText, replacementText) => {
- const execution = await executeEditTransaction(
- {
- bookDir: (targetBookId) => state.bookDir(targetBookId),
- loadChapterIndex: (targetBookId) => state.loadChapterIndex(targetBookId),
- saveChapterIndex: (targetBookId, index) => state.saveChapterIndex(targetBookId, index),
- },
- {
- kind: "chapter-local-edit",
- bookId,
- chapterNumber,
- instruction: `Replace ${targetText} with ${replacementText}`,
- targetText,
- replacementText,
- },
- );
- return {
- __interaction: {
- activeChapterNumber: chapterNumber,
- responseText: execution.summary,
- },
- };
- },
- renameEntity: async (bookId, oldValue, newValue) => {
- const execution = await executeEditTransaction(
- {
- bookDir: (targetBookId) => state.bookDir(targetBookId),
- loadChapterIndex: (targetBookId) => state.loadChapterIndex(targetBookId),
- saveChapterIndex: (targetBookId, index) => state.saveChapterIndex(targetBookId, index),
- },
- {
- kind: "entity-rename",
- bookId,
- entityType: "character",
- oldValue,
- newValue,
- },
- );
- return {
- __interaction: {
- responseText: execution.summary,
- },
- };
- },
- updateCurrentFocus: async (bookId, content) => {
- await state.ensureControlDocuments(bookId);
- await writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), content, "utf-8");
- },
- updateAuthorIntent: async (bookId, content) => {
- await state.ensureControlDocuments(bookId);
- await writeFile(join(state.bookDir(bookId), "story", "author_intent.md"), content, "utf-8");
- },
- writeTruthFile: async (bookId, fileName, content) => {
- await state.ensureControlDocuments(bookId);
- await writeFile(join(state.bookDir(bookId), "story", fileName), content, "utf-8");
- },
- };
- }
|