mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-28 23:02:03 +08:00
feat(core): import_chapters agent tool for chat-driven novel import (#324)
- 新增 import_chapters 工具:把本地文件/目录(含对话附件的 stored_path)导入为 书籍真实章节,复用 PipelineRunner.importChapters(逆向生成设定 + 顺序重放) - 目录/单文件读取与分章逻辑在 core 侧实现(chapter-import-source.ts),CLI 版不动 - 注册进 chat 与 book 模式,系统提示说明与 ingest_material(只存参考资料)的分工 - 书已有章节且未传 resumeFrom 时抛错提示
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { StateManager } from "../state/manager.js";
|
||||
import { createImportChaptersTool } from "../agent/agent-tools.js";
|
||||
|
||||
function mockPipeline() {
|
||||
return {
|
||||
importChapters: vi.fn(async (input: { bookId: string; chapters: ReadonlyArray<{ title: string; content: string }> }) => ({
|
||||
bookId: input.bookId,
|
||||
importedCount: input.chapters.length,
|
||||
totalWords: 4200,
|
||||
nextChapter: input.chapters.length + 1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
describe("import_chapters agent tool", () => {
|
||||
let root: string;
|
||||
let state: StateManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "inkos-import-chapters-tool-"));
|
||||
state = new StateManager(root);
|
||||
|
||||
await state.saveBookConfig("harbor", {
|
||||
id: "harbor",
|
||||
title: "Harbor",
|
||||
platform: "tomato",
|
||||
genre: "other",
|
||||
status: "active",
|
||||
targetChapters: 20,
|
||||
chapterWordCount: 3000,
|
||||
createdAt: "2026-07-07T00:00:00.000Z",
|
||||
updatedAt: "2026-07-07T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("imports a directory of .md/.txt files in filename order with prefix-stripped titles", async () => {
|
||||
const sourceDir = join(root, "source-dir");
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
await writeFile(join(sourceDir, "02_风暴.txt"), "码头的雨下了一夜。", "utf-8");
|
||||
await writeFile(join(sourceDir, "01_序章.md"), "林月守着玉印。", "utf-8");
|
||||
await writeFile(join(sourceDir, "notes.pdf"), "ignored", "utf-8");
|
||||
|
||||
const pipeline = mockPipeline();
|
||||
const tool = createImportChaptersTool(pipeline as never, "harbor", root);
|
||||
|
||||
const result = await tool.execute("tool-import-dir", { sourcePath: sourceDir });
|
||||
|
||||
expect(pipeline.importChapters).toHaveBeenCalledTimes(1);
|
||||
expect(pipeline.importChapters).toHaveBeenCalledWith({
|
||||
bookId: "harbor",
|
||||
chapters: [
|
||||
{ title: "序章", content: "林月守着玉印。" },
|
||||
{ title: "风暴", content: "码头的雨下了一夜。" },
|
||||
],
|
||||
resumeFrom: undefined,
|
||||
importMode: undefined,
|
||||
});
|
||||
expect(result.content[0]?.type).toBe("text");
|
||||
if (result.content[0]?.type === "text") {
|
||||
expect(result.content[0].text).toContain('Imported 2 chapter(s) into book "harbor"');
|
||||
expect(result.content[0].text).toContain("Next chapter to write: 3");
|
||||
}
|
||||
expect(result.details).toMatchObject({
|
||||
kind: "chapters_imported",
|
||||
bookId: "harbor",
|
||||
importedCount: 2,
|
||||
totalWords: 4200,
|
||||
nextChapter: 3,
|
||||
importMode: "continuation",
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-splits a single file by chapter headings and resolves project-relative stored_path", async () => {
|
||||
await mkdir(join(root, ".inkos", "uploads", "s1"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, ".inkos", "uploads", "s1", "novel.txt"),
|
||||
"第一章 开局\n\n他在码头醒来。\n\n第二章 反转\n\n账本不见了。\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const pipeline = mockPipeline();
|
||||
const tool = createImportChaptersTool(pipeline as never, "harbor", root);
|
||||
|
||||
await tool.execute("tool-import-file", { sourcePath: ".inkos/uploads/s1/novel.txt" });
|
||||
|
||||
expect(pipeline.importChapters).toHaveBeenCalledWith({
|
||||
bookId: "harbor",
|
||||
chapters: [
|
||||
{ title: "开局", content: "他在码头醒来。" },
|
||||
{ title: "反转", content: "账本不见了。" },
|
||||
],
|
||||
resumeFrom: undefined,
|
||||
importMode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("splits a single file with a custom splitPattern", async () => {
|
||||
const sourceFile = join(root, "novel-custom.txt");
|
||||
await writeFile(sourceFile, "Part 序幕\n雨夜。\nPart 终局\n天亮。\n", "utf-8");
|
||||
|
||||
const pipeline = mockPipeline();
|
||||
const tool = createImportChaptersTool(pipeline as never, "harbor", root);
|
||||
|
||||
await tool.execute("tool-import-custom-split", {
|
||||
sourcePath: sourceFile,
|
||||
splitPattern: "^Part\\s+(.*)$",
|
||||
});
|
||||
|
||||
expect(pipeline.importChapters).toHaveBeenCalledWith({
|
||||
bookId: "harbor",
|
||||
chapters: [
|
||||
{ title: "序幕", content: "雨夜。" },
|
||||
{ title: "终局", content: "天亮。" },
|
||||
],
|
||||
resumeFrom: undefined,
|
||||
importMode: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when the single file yields no chapters", async () => {
|
||||
const sourceFile = join(root, "no-headings.txt");
|
||||
await writeFile(sourceFile, "只有正文,没有任何章节标题。", "utf-8");
|
||||
|
||||
const pipeline = mockPipeline();
|
||||
const tool = createImportChaptersTool(pipeline as never, "harbor", root);
|
||||
|
||||
await expect(tool.execute("tool-import-no-split", { sourcePath: sourceFile }))
|
||||
.rejects.toThrow(/No chapters found/);
|
||||
expect(pipeline.importChapters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when the book already has chapters and resumeFrom is missing", async () => {
|
||||
await mkdir(join(state.bookDir("harbor"), "chapters"), { recursive: true });
|
||||
await writeFile(
|
||||
join(state.bookDir("harbor"), "chapters", "0001_旧章.md"),
|
||||
"# 第1章 旧章\n\n已有正文。\n",
|
||||
"utf-8",
|
||||
);
|
||||
await state.saveChapterIndex("harbor", [{
|
||||
number: 1,
|
||||
title: "旧章",
|
||||
status: "imported",
|
||||
wordCount: 10,
|
||||
createdAt: "2026-07-07T00:00:00.000Z",
|
||||
updatedAt: "2026-07-07T00:00:00.000Z",
|
||||
auditIssues: [],
|
||||
lengthWarnings: [],
|
||||
}]);
|
||||
|
||||
const sourceDir = join(root, "source-dir");
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
await writeFile(join(sourceDir, "01_新章.md"), "新的正文。", "utf-8");
|
||||
|
||||
const pipeline = mockPipeline();
|
||||
const tool = createImportChaptersTool(pipeline as never, "harbor", root);
|
||||
|
||||
await expect(tool.execute("tool-import-conflict", { sourcePath: sourceDir }))
|
||||
.rejects.toThrow(/already has 1 chapter\(s\).*resumeFrom/);
|
||||
expect(pipeline.importChapters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes resumeFrom and importMode through to pipeline.importChapters", async () => {
|
||||
await mkdir(join(state.bookDir("harbor"), "chapters"), { recursive: true });
|
||||
await writeFile(
|
||||
join(state.bookDir("harbor"), "chapters", "0001_旧章.md"),
|
||||
"# 第1章 旧章\n\n已有正文。\n",
|
||||
"utf-8",
|
||||
);
|
||||
await state.saveChapterIndex("harbor", [{
|
||||
number: 1,
|
||||
title: "旧章",
|
||||
status: "imported",
|
||||
wordCount: 10,
|
||||
createdAt: "2026-07-07T00:00:00.000Z",
|
||||
updatedAt: "2026-07-07T00:00:00.000Z",
|
||||
auditIssues: [],
|
||||
lengthWarnings: [],
|
||||
}]);
|
||||
|
||||
const sourceDir = join(root, "source-dir");
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
await writeFile(join(sourceDir, "01_旧章.md"), "已有正文。", "utf-8");
|
||||
await writeFile(join(sourceDir, "02_新章.md"), "新的正文。", "utf-8");
|
||||
|
||||
const pipeline = mockPipeline();
|
||||
const tool = createImportChaptersTool(pipeline as never, "harbor", root);
|
||||
|
||||
const result = await tool.execute("tool-import-resume", {
|
||||
sourcePath: sourceDir,
|
||||
resumeFrom: 2,
|
||||
importMode: "series",
|
||||
});
|
||||
|
||||
expect(pipeline.importChapters).toHaveBeenCalledWith({
|
||||
bookId: "harbor",
|
||||
chapters: [
|
||||
{ title: "旧章", content: "已有正文。" },
|
||||
{ title: "新章", content: "新的正文。" },
|
||||
],
|
||||
resumeFrom: 2,
|
||||
importMode: "series",
|
||||
});
|
||||
expect(result.content[0]?.type).toBe("text");
|
||||
if (result.content[0]?.type === "text") {
|
||||
expect(result.content[0].text).toContain("Resumed replay from chapter 2");
|
||||
}
|
||||
expect(result.details).toMatchObject({ importMode: "series" });
|
||||
});
|
||||
|
||||
it("rejects a bookId that does not match the active book", async () => {
|
||||
const pipeline = mockPipeline();
|
||||
const tool = createImportChaptersTool(pipeline as never, "harbor", root);
|
||||
|
||||
await expect(tool.execute("tool-import-wrong-book", {
|
||||
bookId: "other-book",
|
||||
sourcePath: join(root, "whatever.txt"),
|
||||
})).rejects.toThrow(/must match the active book/);
|
||||
expect(pipeline.importChapters).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -571,6 +571,7 @@ describe("runAgentSession cache — bookId switch", () => {
|
||||
"research_web",
|
||||
"ingest_material",
|
||||
"retrieve_material",
|
||||
"import_chapters",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -894,6 +895,7 @@ describe("runAgentSession cache — bookId switch", () => {
|
||||
"research_web",
|
||||
"ingest_material",
|
||||
"retrieve_material",
|
||||
"import_chapters",
|
||||
"grep",
|
||||
"ls",
|
||||
]);
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
createResearchWebTool,
|
||||
createIngestMaterialTool,
|
||||
createRetrieveMaterialTool,
|
||||
createImportChaptersTool,
|
||||
} from "./agent-tools.js";
|
||||
import { createFilmAuthoringTools, filmLLMDepsFromClient } from "./film-authoring-tools.js";
|
||||
import { createBookContextTransform } from "./context-transform.js";
|
||||
@@ -757,6 +758,7 @@ function createAgentToolsForMode(params: {
|
||||
const researchTool = createResearchWebTool(params.projectRoot);
|
||||
const materialTool = createIngestMaterialTool(params.projectRoot);
|
||||
const materialRetrievalTool = createRetrieveMaterialTool(params.projectRoot);
|
||||
const importChaptersTool = createImportChaptersTool(params.pipeline, params.bookId, params.projectRoot);
|
||||
const isConfirmed = (
|
||||
intent: NonNullable<AgentSessionConfig["requestedIntent"]>,
|
||||
): boolean => {
|
||||
@@ -765,7 +767,7 @@ function createAgentToolsForMode(params: {
|
||||
};
|
||||
|
||||
if (params.sessionKind === "chat") {
|
||||
return [proposalTool, researchTool, materialTool, materialRetrievalTool];
|
||||
return [proposalTool, researchTool, materialTool, materialRetrievalTool, importChaptersTool];
|
||||
}
|
||||
|
||||
if (params.sessionKind === "short") {
|
||||
@@ -857,12 +859,13 @@ function createAgentToolsForMode(params: {
|
||||
researchTool,
|
||||
materialTool,
|
||||
materialRetrievalTool,
|
||||
importChaptersTool,
|
||||
createGrepTool(params.projectRoot),
|
||||
createLsTool(params.projectRoot),
|
||||
];
|
||||
|
||||
if (params.sessionKind === "edit") {
|
||||
return bookTools.filter((tool) => !["sub_agent", "generate_cover", "research_web"].includes(tool.name));
|
||||
return bookTools.filter((tool) => !["sub_agent", "generate_cover", "research_web", "import_chapters"].includes(tool.name));
|
||||
}
|
||||
|
||||
return bookTools;
|
||||
|
||||
@@ -37,28 +37,30 @@ function buildChatPrompt(isZh: boolean): string {
|
||||
|
||||
这里不是自动生产入口。用户讨论、提问、比较方案时,直接回答。
|
||||
|
||||
可用工具:propose_action、research_web、ingest_material、retrieve_material。用户明确要创建长篇、生成短篇、启动互动世界、生成封面、创建剧本、创建分镜,或打开同人/续写/番外/仿写辅助入口时调用 propose_action。用户明确要求联网研究、事实核查、年代/职业/世界观资料时调用 research_web。用户给出 URL、上传 PDF/Markdown/文本资料,或要求“把这个资料纳入参考库/先读这份资料”时调用 ingest_material。用户要求基于已归档资料回答、整理、对照或继续创作时,先用 retrieve_material 按当前任务召回相关片段;资料卡只是参考材料,不会自动改设定或正文。
|
||||
可用工具:propose_action、research_web、ingest_material、retrieve_material、import_chapters。用户明确要创建长篇、生成短篇、启动互动世界、生成封面、创建剧本、创建分镜,或打开同人/续写/番外/仿写辅助入口时调用 propose_action。用户明确要求联网研究、事实核查、年代/职业/世界观资料时调用 research_web。用户给出 URL、上传 PDF/Markdown/文本资料,或要求“把这个资料纳入参考库/先读这份资料”时调用 ingest_material。用户要求基于已归档资料回答、整理、对照或继续创作时,先用 retrieve_material 按当前任务召回相关片段;资料卡只是参考材料,不会自动改设定或正文。
|
||||
用户要把已有小说的章节文件或整本文稿导入成某本书的正式章节(InkOS 会逆向生成设定文件)时调用 import_chapters;只是想把资料存成参考材料时用 ingest_material,两者不要混用。import_chapters 需要明确的目标 bookId(必须是已存在的书;没有书就先走建书流程)和本地文件/目录路径,路径可以直接用“用户上传文件”区块里的 stored_path,也可以是用户说明的本机绝对路径。
|
||||
|
||||
生产型动作:create_book、short_run、play_start、generate_cover、script_create、storyboard_create、interactive_film_create。确认后会切换到对应 session 执行。
|
||||
辅助入口动作:fanfic_init、continuation_import、spinoff_create、style_imitation。确认后只打开现有 Studio 工具,不能声称已经生成成品。
|
||||
辅助入口是“打开工具并准备材料”,不是立即生成成品。用户明确提到“同人 / 续写 / 番外 / 仿写 / 文风分析 / 参考文风 / 模仿笔法 / 先分析再仿写”时,必须调用 propose_action,不要用普通文字追问书名、原文、父书路径或解释流程。材料缺失时从用户方向临时概括一个短标题,instruction 里写清“待用户在入口补充材料”。映射:同人=fanfic_init,续写=continuation_import,番外/正典资料/不进入主线=spinoff_create,仿写/文风分析/参考文风/模仿笔法=style_imitation。确认卡标题/摘要必须说“打开入口 / 准备材料”,不要说“直接生成成品”。
|
||||
|
||||
调用 propose_action 时,instruction 必须自包含:写清目标入口、标题/书名/路径、故事或视觉方向、用户提到的关键上下文;不要让下一条 session 依赖上一轮聊天上下文猜。能确定的执行参数必须同时填进结构化字段:createBook / shortRun / playStart / generateCover / scriptCreate / storyboardCreate / interactiveFilmCreate,不要只写在 instruction 文本里。互动世界如果用户说“开放世界/自由玩/自己行动”,playStart.mode 填 open;如果用户说“分支互动/点着玩/给选项”,playStart.mode 填 guided。互动影游/互动剧/影游交付/盛世天下式多结局剧本,使用 interactive_film_create,不要路由到 play_start。
|
||||
信息不足时只问一个关键问题。不要在 chat 里创建、写入、编辑或生成故事/图片产物;research_web、ingest_material 和 retrieve_material 只处理参考材料除外。
|
||||
信息不足时只问一个关键问题。不要在 chat 里创建、写入、编辑或生成故事/图片产物;research_web、ingest_material 和 retrieve_material 只处理参考材料除外,import_chapters 是唯一会写入书籍章节的例外,只在用户明确要求导入已有章节时调用。
|
||||
|
||||
${commonOutputRules(true)}`
|
||||
: `You are the InkOS general chat assistant.
|
||||
|
||||
This is not an automatic production surface. Answer questions, discussion, comparisons, and issue reports directly.
|
||||
|
||||
Available tools: propose_action, research_web, ingest_material, and retrieve_material. Use propose_action when the user clearly wants to create a book, run short fiction, start a play world, generate a cover, create a script, create a storyboard, or open assisted fanfiction / continuation / side-story / style-imitation workflows. Use research_web when the user explicitly asks for web research, fact checking, era/profession/worldbuilding references, or market research. Use ingest_material when the user provides a URL, uploaded PDF/Markdown/text file, or asks to archive/read provided materials. Use retrieve_material before answering, comparing, or continuing from archived materials. Research reports and material cards are reference material only and do not automatically change canon or prose.
|
||||
Available tools: propose_action, research_web, ingest_material, retrieve_material, and import_chapters. Use propose_action when the user clearly wants to create a book, run short fiction, start a play world, generate a cover, create a script, create a storyboard, or open assisted fanfiction / continuation / side-story / style-imitation workflows. Use research_web when the user explicitly asks for web research, fact checking, era/profession/worldbuilding references, or market research. Use ingest_material when the user provides a URL, uploaded PDF/Markdown/text file, or asks to archive/read provided materials. Use retrieve_material before answering, comparing, or continuing from archived materials. Research reports and material cards are reference material only and do not automatically change canon or prose.
|
||||
Use import_chapters when the user wants existing novel chapters or a full manuscript imported into a book as real chapters (InkOS reverse-engineers the truth files from the text); use ingest_material when they only want reference material archived — do not confuse the two. import_chapters requires an explicit target bookId (an existing book; if none exists, create the book first) and a local file/directory path: the stored_path from the Uploaded Files block works, and so does an absolute path the user names on this machine.
|
||||
|
||||
Production actions: create_book, short_run, play_start, generate_cover, script_create, storyboard_create, interactive_film_create. After confirmation, InkOS switches to the matching session and runs them.
|
||||
Assisted workflow actions: fanfic_init, continuation_import, spinoff_create, style_imitation. After confirmation, InkOS only opens the existing Studio tool; do not claim finished content was generated.
|
||||
Assisted workflows open a tool and prepare materials; they do not immediately generate finished content. When the user explicitly asks for fanfiction, continuation, side-story/spinoff, style imitation, style analysis, reference-style analysis, prose mimicry, or "analyze first then imitate", you must call propose_action. Do not answer by asking for a title/source text/parent-book path or by explaining the workflow in plain text. If materials are missing, infer a short temporary title from the user's direction, and say in the instruction that the user will fill missing materials in the opened tool. Mapping: fanfiction=fanfic_init, continuation=continuation_import, side-story/spinoff/canon-materials=spinoff_create, style imitation/style analysis/reference-style/prose mimicry=style_imitation. The confirmation card title/summary must say "open workflow / prepare materials"; do not say finished content will be generated.
|
||||
|
||||
When calling propose_action, instruction must be self-contained: include target surface, title/book/path, story or visual direction, and concrete context behind references like "that book" or "this cover". Do not make the next session infer missing context from this chat. Put known execution arguments into the structured createBook / shortRun / playStart / generateCover / scriptCreate / storyboardCreate / interactiveFilmCreate fields as well; do not leave them only in instruction text. For interactive worlds, set playStart.mode=open when the user asks for open/free-form play, and playStart.mode=guided when the user asks for branching/choice-led play. For interactive film/drama/game-script deliverables with branch logic, flags, endings, scripts, and storyboards, use interactive_film_create instead of play_start.
|
||||
If information is missing, ask one key question. Do not create, write, edit, or generate story/image artifacts in chat; research_web, ingest_material, and retrieve_material are reference-material-only exceptions.
|
||||
If information is missing, ask one key question. Do not create, write, edit, or generate story/image artifacts in chat; research_web, ingest_material, and retrieve_material are reference-material-only exceptions, and import_chapters is the only exception that writes book chapters — call it only when the user explicitly asks to import existing chapters.
|
||||
|
||||
${commonOutputRules(false)}`;
|
||||
}
|
||||
@@ -485,6 +487,7 @@ function buildBookPrompt(bookId: string, isZh: boolean): string {
|
||||
- research_web:用户明确要求联网研究、事实核查、年代/职业/地域/制度资料时使用;报告保存为参考材料,不会自动改当前书设定或正文。
|
||||
- ingest_material:用户给 URL、上传 PDF/Markdown/文本资料,或要求“先读/归档这份资料”时使用;资料卡保存在 .inkos/materials,不会自动改当前书设定或正文。
|
||||
- retrieve_material:基于当前任务从 .inkos/materials 召回相关片段;返回带路径和字符范围的证据指针。它只读取参考资料,不改设定或正文。
|
||||
- import_chapters:把用户提供的已有小说章节(本地文件或目录,路径可用“用户上传文件”区块里的 stored_path,也可以是用户给出的绝对路径)导入当前书成为正式章节,并逆向生成设定文件。目录模式按文件名排序、每个 .md/.txt 文件一章;单文件模式默认按“第X章/Chapter N”标题自动分章,可用 splitPattern 自定义正则。当前书已有章节时必须传 resumeFrom 续导,否则会报错。它和 ingest_material 的区别:ingest_material 只存参考资料,import_chapters 会写入章节和设定。
|
||||
- grep:搜索内容。
|
||||
- ls:列出文件或章节。
|
||||
|
||||
@@ -507,6 +510,7 @@ function buildBookPrompt(bookId: string, isZh: boolean): string {
|
||||
- 用户粘贴/提供某章完整新正文并要求替换 → replace_chapter_text。
|
||||
- 用户要求生成或重做封面 → generate_cover。
|
||||
- 用户要求查外部事实、年代职业细节、真实地域制度资料 → research_web;用户提供 URL/PDF/文本资料 → ingest_material;用户要求基于已归档资料回答、对照、续写或整理 → retrieve_material;如需把研究结果或资料内容写入设定,必须再由用户明确确认后用 write_truth_file。
|
||||
- 用户要求把已有小说/章节/整本文稿导入当前书(成为正式章节并生成设定)→ import_chapters;只是提供参考资料、不要求进正文 → ingest_material。
|
||||
- 其他普通讨论 → 直接回答。
|
||||
|
||||
## 章节索引
|
||||
@@ -543,6 +547,7 @@ ${commonOutputRules(true)}`
|
||||
- research_web: collect web research or fact checks for era/profession/region/institution details. Reports are saved as reference material and do not automatically change canon or prose.
|
||||
- ingest_material: archive a user-provided URL, uploaded PDF, Markdown, or text file into .inkos/materials. Material cards are references only and do not automatically change canon or prose.
|
||||
- retrieve_material: retrieve task-relevant snippets from .inkos/materials with path and character-range evidence pointers. It reads reference materials only and does not change canon or prose.
|
||||
- import_chapters: import the user's existing novel chapters (a local file or directory; the path can be the stored_path from the Uploaded Files block or an absolute path the user names) into the active book as real chapters, reverse-engineering the truth files. Directory mode imports each .md/.txt file as one chapter in filename order; single-file mode auto-splits on "第X章"/"Chapter N" headings, with splitPattern for a custom regex. When the book already has chapters, resumeFrom is required, otherwise it errors. Difference from ingest_material: ingest_material archives reference material only, import_chapters writes chapters and settings.
|
||||
- grep: search content.
|
||||
- ls: list files or chapters.
|
||||
|
||||
@@ -565,6 +570,7 @@ ${commonOutputRules(true)}`
|
||||
- User-provided full replacement for an existing chapter → replace_chapter_text.
|
||||
- Cover generation/regeneration → generate_cover.
|
||||
- External facts, era/profession details, or real-world regional/institutional references → research_web. User-provided URLs/PDF/text files → ingest_material. Archived-material questions, comparisons, continuations, or summaries → retrieve_material. If research or material content should affect canon, wait for explicit confirmation and then use write_truth_file.
|
||||
- The user wants existing novel chapters or a full manuscript imported into the active book (as real chapters with reverse-engineered settings) → import_chapters. The user only provides reference material without asking it to become prose → ingest_material.
|
||||
- Ordinary discussion → answer directly.
|
||||
|
||||
## Chapter Index
|
||||
|
||||
@@ -18,6 +18,7 @@ import { runInteractiveFilmCreation, runScriptCreation, runStoryboardCreation }
|
||||
import { runResearchReport } from "../agents/researcher.js";
|
||||
import { ingestMaterial } from "../materials/ingest.js";
|
||||
import { retrieveMaterials } from "../materials/retrieve.js";
|
||||
import { loadChaptersFromPath } from "./chapter-import-source.js";
|
||||
import type { ScriptTargetFormat } from "../agents/script-storyboard.js";
|
||||
import { createPlayDB, type PlayGraphDB } from "../play/play-db-factory.js";
|
||||
import { PlayRunner, type PlayOpeningSeedResult, type PlayReplayResult, type PlayStepResult, type PlayVariantRestoreResult } from "../play/play-runner.js";
|
||||
@@ -1063,6 +1064,100 @@ export function createRetrieveMaterialTool(projectRoot: string): AgentTool<typeo
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Chapter Import Tool (import_chapters)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ImportChaptersParams = Type.Object({
|
||||
bookId: Type.Optional(Type.String({
|
||||
description: "Target book ID to import into. In active-book sessions, omit it to use the current active book; if provided, it must match the active book. In general chat there is no active book, so it is required and must be an existing book.",
|
||||
})),
|
||||
sourcePath: Type.String({
|
||||
description: "Local path of the chapter source: either the stored_path from the Uploaded Files block (project-relative, e.g. .inkos/uploads/<session>/novel.txt) or an absolute path on this machine that the user provided. A directory imports each .md/.txt file as one chapter in filename order; a single file is split into chapters automatically by heading lines.",
|
||||
}),
|
||||
splitPattern: Type.Optional(Type.String({
|
||||
description: "Single-file mode only: custom JavaScript regex source matching chapter heading lines. Omit to use the default pattern, which matches \"第X章/第X回\" and \"Chapter N\" headings.",
|
||||
})),
|
||||
resumeFrom: Type.Optional(Type.Number({
|
||||
description: "Resume an interrupted import from chapter N (1-based). Required when the book already has chapters: replay starts at chapter N and earlier chapters are kept. Omit for a fresh import into an empty book.",
|
||||
})),
|
||||
importMode: Type.Optional(Type.Union([
|
||||
Type.Literal("continuation"),
|
||||
Type.Literal("series"),
|
||||
], {
|
||||
description: "continuation (default): the book picks up exactly where the imported text left off, no new spacetime. series: shared universe but an independent new story, so a new spacetime is generated.",
|
||||
})),
|
||||
});
|
||||
|
||||
type ImportChaptersParamsType = Static<typeof ImportChaptersParams>;
|
||||
|
||||
export function createImportChaptersTool(
|
||||
pipeline: PipelineRunner,
|
||||
activeBookId: string | null,
|
||||
projectRoot: string,
|
||||
): AgentTool<typeof ImportChaptersParams> {
|
||||
return {
|
||||
name: "import_chapters",
|
||||
description:
|
||||
"Import an existing novel's chapters from a local file or directory into an InkOS book as real chapters (not reference material). " +
|
||||
"InkOS reverse-engineers foundation/truth files from the imported text and replays every chapter to rebuild story state, so the book can be continued afterwards. " +
|
||||
"Use ingest_material instead when the user only wants to archive reference material without touching book chapters.",
|
||||
label: "Import Chapters",
|
||||
parameters: ImportChaptersParams,
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: ImportChaptersParamsType,
|
||||
_signal?: AbortSignal,
|
||||
onUpdate?: AgentToolUpdateCallback,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const targetBookId = resolveToolBookId("import_chapters", params.bookId, activeBookId);
|
||||
|
||||
const state = new StateManager(projectRoot);
|
||||
const existingChapterCount = (await state.getNextChapterNumber(targetBookId)) - 1;
|
||||
if (existingChapterCount > 0 && params.resumeFrom === undefined) {
|
||||
throw new Error(
|
||||
`Book "${targetBookId}" already has ${existingChapterCount} chapter(s). ` +
|
||||
`Pass resumeFrom=<n> to resume/append from chapter n, or ask the user to clear the existing chapters first.`,
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSourcePath = isAbsolute(params.sourcePath)
|
||||
? params.sourcePath
|
||||
: resolve(projectRoot, params.sourcePath);
|
||||
onUpdate?.(textResult(`Reading chapters from ${resolvedSourcePath}...`));
|
||||
const chapters = await loadChaptersFromPath(resolvedSourcePath, params.splitPattern);
|
||||
|
||||
onUpdate?.(textResult(`Found ${chapters.length} chapter(s); importing into "${targetBookId}"...`));
|
||||
const result = await pipeline.importChapters({
|
||||
bookId: targetBookId,
|
||||
chapters,
|
||||
resumeFrom: params.resumeFrom,
|
||||
importMode: params.importMode,
|
||||
});
|
||||
|
||||
const regeneratedFoundation = (params.resumeFrom ?? 1) === 1;
|
||||
return textResult(
|
||||
[
|
||||
`Imported ${result.importedCount} chapter(s) into book "${result.bookId}".`,
|
||||
`Total imported length: ${result.totalWords}. Next chapter to write: ${result.nextChapter}.`,
|
||||
regeneratedFoundation
|
||||
? "Foundation and truth files were reverse-engineered from the imported text; chapter files and the chapter index were rebuilt by sequential replay."
|
||||
: `Resumed replay from chapter ${params.resumeFrom}; earlier chapters and the existing foundation were kept.`,
|
||||
`The book can now be continued with sub_agent(agent="writer") in the book session.`,
|
||||
].join("\n"),
|
||||
{
|
||||
kind: "chapters_imported",
|
||||
bookId: result.bookId,
|
||||
importedCount: result.importedCount,
|
||||
totalWords: result.totalWords,
|
||||
nextChapter: result.nextChapter,
|
||||
importMode: params.importMode ?? "continuation",
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function slugResearchTopic(topic: string): string {
|
||||
const slug = topic
|
||||
.normalize("NFKC")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFile, readdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { splitChapters, type SplitChapter } from "../utils/chapter-splitter.js";
|
||||
|
||||
/**
|
||||
* Load chapters from a local source path for `import_chapters`.
|
||||
*
|
||||
* - Directory mode: each `.md`/`.txt` file becomes one chapter, in filename
|
||||
* sort order. The chapter title is the filename without its extension and
|
||||
* without a leading numeric prefix (e.g. `03_风暴.md` → `风暴`).
|
||||
* - Single-file mode: the file is split into chapters with `splitChapters`,
|
||||
* using `splitPattern` as a custom heading regex when provided.
|
||||
*
|
||||
* This mirrors the pure loading logic of `inkos import chapters` in the CLI
|
||||
* so the agent tool does not depend on the CLI package.
|
||||
*/
|
||||
export async function loadChaptersFromPath(
|
||||
sourcePath: string,
|
||||
splitPattern?: string,
|
||||
): Promise<ReadonlyArray<SplitChapter>> {
|
||||
const sourceStat = await stat(sourcePath);
|
||||
|
||||
if (sourceStat.isDirectory()) {
|
||||
const entries = await readdir(sourcePath);
|
||||
const textFiles = entries
|
||||
.filter((f) => f.endsWith(".md") || f.endsWith(".txt"))
|
||||
.sort();
|
||||
|
||||
if (textFiles.length === 0) {
|
||||
throw new Error(`No .md or .txt files found in ${sourcePath}.`);
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
textFiles.map(async (f) => {
|
||||
const content = await readFile(join(sourcePath, f), "utf-8");
|
||||
const title = f.replace(/\.(md|txt)$/, "").replace(/^\d+[_\-\s]*/, "");
|
||||
return { title, content };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const text = await readFile(sourcePath, "utf-8");
|
||||
const chapters = splitChapters(text, splitPattern);
|
||||
|
||||
if (chapters.length === 0) {
|
||||
throw new Error(
|
||||
`No chapters found in ${sourcePath}. ` +
|
||||
`The default pattern matches "第X章/第X回" and "Chapter N" heading lines. ` +
|
||||
`Pass splitPattern with a custom regex if the source uses a different heading style.`,
|
||||
);
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
createInteractiveFilmCreationTool,
|
||||
createResearchWebTool,
|
||||
createIngestMaterialTool,
|
||||
createImportChaptersTool,
|
||||
createGenerateCoverTool,
|
||||
createPlayStartTool,
|
||||
createPlayReviseTool,
|
||||
|
||||
Reference in New Issue
Block a user