mirror of
https://github.com/Narcooo/inkos.git
synced 2026-09-01 15:08:51 +08:00
refactor(agent): remove legacy nl execution paths
This commit is contained in:
@@ -1,142 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createProgram } from "../program.js";
|
||||
import { createInteractCommand } from "../commands/interact.js";
|
||||
|
||||
const {
|
||||
buildPipelineConfigMock,
|
||||
createClientMock,
|
||||
findProjectRootMock,
|
||||
loadConfigMock,
|
||||
runAgentSessionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
buildPipelineConfigMock: vi.fn(() => ({})),
|
||||
createClientMock: vi.fn(() => ({
|
||||
_piModel: {
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
api: "openai-completions",
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
_apiKey: "secret",
|
||||
})),
|
||||
findProjectRootMock: vi.fn(() => "/tmp/inkos-project"),
|
||||
loadConfigMock: vi.fn(async () => ({
|
||||
llm: {
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
apiFormat: "chat",
|
||||
stream: false,
|
||||
},
|
||||
language: "zh",
|
||||
})),
|
||||
runAgentSessionMock: vi.fn(async () => ({
|
||||
responseText: "Agent response.",
|
||||
messages: [{ role: "assistant", content: "Agent response." }],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@actalk/inkos-core", async () => ({
|
||||
PipelineRunner: class PipelineRunnerMock {
|
||||
constructor(_config: unknown) {}
|
||||
},
|
||||
runAgentSession: runAgentSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../utils.js", () => ({
|
||||
buildPipelineConfig: buildPipelineConfigMock,
|
||||
createClient: createClientMock,
|
||||
findProjectRoot: findProjectRootMock,
|
||||
loadConfig: loadConfigMock,
|
||||
}));
|
||||
|
||||
describe("interact command", () => {
|
||||
const originalArgv = process.argv;
|
||||
let projectRoot: string;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn> | {
|
||||
mockClear: () => void;
|
||||
mock: { calls: Array<ReadonlyArray<unknown>> };
|
||||
};
|
||||
let stdoutOutput: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
||||
projectRoot = await mkdtemp(join(tmpdir(), "inkos-interact-cli-"));
|
||||
await mkdir(join(projectRoot, "books", "harbor"), { recursive: true });
|
||||
await writeFile(join(projectRoot, "books", "harbor", "book.json"), "{}", "utf-8");
|
||||
stdoutSpy.mockClear();
|
||||
process.argv = originalArgv;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stdoutOutput = [];
|
||||
vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => {
|
||||
stdoutOutput.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("routes natural language through the shared executor and prints plain text by default", async () => {
|
||||
const runInteraction = vi.fn(async () => ({
|
||||
request: { intent: "write_next" },
|
||||
responseText: "Continuing harbor.",
|
||||
session: {
|
||||
activeBookId: "harbor",
|
||||
automationMode: "semi",
|
||||
messages: [{ role: "assistant", content: "Continuing harbor.", timestamp: 1 }],
|
||||
events: [{ kind: "task.completed", status: "completed", timestamp: 1 }],
|
||||
},
|
||||
}));
|
||||
it("routes natural language through runAgentSession", async () => {
|
||||
const command = createInteractCommand({ readInput: async () => "" });
|
||||
|
||||
const program = createProgram({
|
||||
runInteraction,
|
||||
readInteractionInput: async () => "",
|
||||
});
|
||||
await command.parseAsync(["continue", "--book", "harbor"], { from: "user" });
|
||||
|
||||
await program.parseAsync(["interact", "continue", "--book", "harbor"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(runInteraction).toHaveBeenCalledWith(
|
||||
expect(runAgentSessionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectRoot: process.cwd(),
|
||||
input: "continue",
|
||||
activeBookId: "harbor",
|
||||
projectRoot: "/tmp/inkos-project",
|
||||
bookId: "harbor",
|
||||
sessionKind: "book",
|
||||
actionSource: "free-text",
|
||||
requestedIntent: undefined,
|
||||
}),
|
||||
"continue",
|
||||
);
|
||||
expect(stdoutSpy).toHaveBeenCalledWith(expect.stringContaining("Continuing harbor."));
|
||||
expect(stdoutOutput.join("")).toContain("Agent response.");
|
||||
});
|
||||
|
||||
it("emits structured JSON when --json is used", async () => {
|
||||
const runInteraction = vi.fn(async () => ({
|
||||
request: { intent: "switch_mode", mode: "auto" },
|
||||
responseText: "Switched to auto.",
|
||||
session: {
|
||||
activeBookId: "harbor",
|
||||
automationMode: "auto",
|
||||
messages: [],
|
||||
events: [{ kind: "task.completed", status: "completed", timestamp: 1 }],
|
||||
},
|
||||
}));
|
||||
it("passes slash write as a requested intent", async () => {
|
||||
const command = createInteractCommand({ readInput: async () => "" });
|
||||
|
||||
const program = createProgram({
|
||||
runInteraction,
|
||||
readInteractionInput: async () => "",
|
||||
});
|
||||
await command.parseAsync(["/write", "--book", "harbor", "--json"], { from: "user" });
|
||||
|
||||
await program.parseAsync(["interact", "切换到全自动", "--book", "harbor", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
const output = stdoutSpy.mock.calls.map((call) => String(call[0])).join("");
|
||||
const parsed = JSON.parse(output);
|
||||
expect(parsed.request.intent).toBe("switch_mode");
|
||||
expect(parsed.responseText).toBe("Switched to auto.");
|
||||
expect(parsed.session.automationMode).toBe("auto");
|
||||
});
|
||||
|
||||
it("accepts --message as an explicit OpenClaw-friendly input channel", async () => {
|
||||
const runInteraction = vi.fn(async () => ({
|
||||
request: { intent: "continue_book" },
|
||||
responseText: "Continuing via --message.",
|
||||
session: {
|
||||
activeBookId: "harbor",
|
||||
automationMode: "semi",
|
||||
messages: [],
|
||||
events: [],
|
||||
},
|
||||
}));
|
||||
|
||||
const program = createProgram({
|
||||
runInteraction,
|
||||
readInteractionInput: async () => "",
|
||||
});
|
||||
|
||||
await program.parseAsync(["interact", "--book", "harbor", "--message", "continue current book"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(runInteraction).toHaveBeenCalledWith(
|
||||
expect(runAgentSessionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: "continue current book",
|
||||
activeBookId: "harbor",
|
||||
bookId: "harbor",
|
||||
sessionKind: "book",
|
||||
actionSource: "slash",
|
||||
requestedIntent: "write_next",
|
||||
}),
|
||||
"/write",
|
||||
);
|
||||
const output = stdoutOutput.join("");
|
||||
expect(JSON.parse(output)).toEqual(expect.objectContaining({
|
||||
responseText: "Agent response.",
|
||||
session: expect.objectContaining({
|
||||
sessionKind: "book",
|
||||
activeBookId: "harbor",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("reads the message from stdin when no args are provided", async () => {
|
||||
const runInteraction = vi.fn(async () => ({
|
||||
request: { intent: "explain_status" },
|
||||
responseText: "Explaining status.",
|
||||
session: {
|
||||
activeBookId: "harbor",
|
||||
automationMode: "semi",
|
||||
messages: [],
|
||||
events: [],
|
||||
},
|
||||
}));
|
||||
it("reads input from the injected stdin helper", async () => {
|
||||
const command = createInteractCommand({ readInput: async () => "why did it stop?" });
|
||||
|
||||
const program = createProgram({
|
||||
runInteraction,
|
||||
readInteractionInput: async () => "why did it stop?",
|
||||
});
|
||||
await command.parseAsync([], { from: "user" });
|
||||
|
||||
await program.parseAsync(["interact"], { from: "user" });
|
||||
|
||||
expect(runInteraction).toHaveBeenCalledWith(
|
||||
expect(runAgentSessionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: "why did it stop?",
|
||||
sessionKind: "chat",
|
||||
actionSource: "free-text",
|
||||
}),
|
||||
"why did it stop?",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createInteractionToolsFromDeps } from "../interaction/tools.js";
|
||||
|
||||
const chapterResult = {
|
||||
chapterNumber: 1,
|
||||
title: "Draft",
|
||||
wordCount: 1200,
|
||||
revised: false,
|
||||
status: "ready-for-review" as const,
|
||||
auditResult: {
|
||||
passed: true,
|
||||
issues: [],
|
||||
summary: "ok",
|
||||
},
|
||||
};
|
||||
|
||||
const reviseResult = {
|
||||
chapterNumber: 3,
|
||||
wordCount: 1200,
|
||||
fixedIssues: [],
|
||||
applied: true,
|
||||
status: "ready-for-review" as const,
|
||||
};
|
||||
|
||||
let projectRoot: string;
|
||||
|
||||
describe("interaction tools adapter", () => {
|
||||
beforeAll(async () => {
|
||||
projectRoot = await mkdtemp(join(tmpdir(), "inkos-interaction-tools-"));
|
||||
await mkdir(join(projectRoot, "books", "harbor", "story"), { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// tmpdir cleanup omitted
|
||||
});
|
||||
|
||||
it("delegates writeNextChapter and reviseDraft to the pipeline", async () => {
|
||||
const pipeline = {
|
||||
writeNextChapter: vi.fn(async () => chapterResult),
|
||||
reviseDraft: vi.fn(async () => reviseResult),
|
||||
};
|
||||
const state = {
|
||||
ensureControlDocuments: vi.fn(async () => {}),
|
||||
bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
|
||||
loadBookConfig: vi.fn(async () => ({
|
||||
id: "harbor",
|
||||
title: "Harbor",
|
||||
platform: "other" as const,
|
||||
genre: "other",
|
||||
status: "outlining" as const,
|
||||
targetChapters: 200,
|
||||
chapterWordCount: 3000,
|
||||
createdAt: "2026-04-10T00:00:00.000Z",
|
||||
updatedAt: "2026-04-10T00:00:00.000Z",
|
||||
})),
|
||||
loadChapterIndex: vi.fn(async () => []),
|
||||
saveChapterIndex: vi.fn(async () => undefined),
|
||||
listBooks: vi.fn(async () => ["harbor"]),
|
||||
};
|
||||
|
||||
const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);
|
||||
|
||||
await tools.writeNextChapter("harbor");
|
||||
await tools.reviseDraft("harbor", 3, "rewrite");
|
||||
|
||||
expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor");
|
||||
expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
|
||||
});
|
||||
|
||||
it("writes current_focus and author_intent through the canonical story paths", async () => {
|
||||
const pipeline = {
|
||||
writeNextChapter: vi.fn(async () => chapterResult),
|
||||
reviseDraft: vi.fn(async () => reviseResult),
|
||||
};
|
||||
const state = {
|
||||
ensureControlDocuments: vi.fn(async () => {}),
|
||||
bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
|
||||
loadBookConfig: vi.fn(async () => ({
|
||||
id: "harbor",
|
||||
title: "Harbor",
|
||||
platform: "other" as const,
|
||||
genre: "other",
|
||||
status: "outlining" as const,
|
||||
targetChapters: 200,
|
||||
chapterWordCount: 3000,
|
||||
createdAt: "2026-04-10T00:00:00.000Z",
|
||||
updatedAt: "2026-04-10T00:00:00.000Z",
|
||||
})),
|
||||
loadChapterIndex: vi.fn(async () => []),
|
||||
saveChapterIndex: vi.fn(async () => undefined),
|
||||
listBooks: vi.fn(async () => ["harbor"]),
|
||||
};
|
||||
|
||||
const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);
|
||||
|
||||
await tools.updateCurrentFocus("harbor", "# Current Focus\n\nBring focus back to the old case.\n");
|
||||
await tools.updateAuthorIntent("harbor", "# Author Intent\n\nWrite a cold harbor mystery.\n");
|
||||
|
||||
await expect(readFile(join(projectRoot, "books", "harbor", "story", "current_focus.md"), "utf-8"))
|
||||
.resolves.toContain("Bring focus back to the old case");
|
||||
await expect(readFile(join(projectRoot, "books", "harbor", "story", "author_intent.md"), "utf-8"))
|
||||
.resolves.toContain("Write a cold harbor mystery");
|
||||
});
|
||||
});
|
||||
@@ -156,11 +156,13 @@ describe("tui agent session bridge", () => {
|
||||
expect(persisted.activeBookId).toBe("night-harbor");
|
||||
});
|
||||
|
||||
it("routes explicit create-book instructions directly to shared book creation", async () => {
|
||||
const initBookSpy = vi.spyOn(
|
||||
(await import("@actalk/inkos-core")).PipelineRunner.prototype as any,
|
||||
"initBook",
|
||||
);
|
||||
it("routes create-book text through the unified agent session instead of parsing it locally", async () => {
|
||||
runAgentSessionMock.mockResolvedValue({
|
||||
responseText: "我理解你想创建《雾灯小巷》,请确认后我再建书。",
|
||||
messages: [
|
||||
{ role: "assistant", content: "我理解你想创建《雾灯小巷》,请确认后我再建书。" },
|
||||
],
|
||||
});
|
||||
const { processTuiAgentInput } = await import("../tui/agent-input.js");
|
||||
const session = createProjectSession(projectRoot);
|
||||
|
||||
@@ -170,27 +172,28 @@ describe("tui agent session bridge", () => {
|
||||
session,
|
||||
});
|
||||
|
||||
expect(runAgentSessionMock).not.toHaveBeenCalled();
|
||||
expect(initBookSpy).toHaveBeenCalledWith(
|
||||
expect(runAgentSessionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "雾灯小巷",
|
||||
title: "雾灯小巷",
|
||||
genre: "urban",
|
||||
platform: "tomato",
|
||||
targetChapters: 10,
|
||||
chapterWordCount: 1200,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
externalContext: expect.stringContaining("雾灯小巷"),
|
||||
sessionKind: "book-create",
|
||||
actionSource: "free-text",
|
||||
requestedIntent: undefined,
|
||||
}),
|
||||
expect.stringContaining("雾灯小巷"),
|
||||
[],
|
||||
);
|
||||
expect(result.session.activeBookId).toBe("雾灯小巷");
|
||||
expect(result.responseText).toContain("已创建");
|
||||
expect(result.session.activeBookId).toBeUndefined();
|
||||
expect(result.responseText).toContain("请确认");
|
||||
const persisted = await loadProjectSession(projectRoot);
|
||||
expect(persisted.activeBookId).toBe("雾灯小巷");
|
||||
expect(persisted.activeBookId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes write-next instructions directly to the writer when a book is active", async () => {
|
||||
it("passes explicit slash write-next as a requested intent to the unified agent session", async () => {
|
||||
runAgentSessionMock.mockResolvedValue({
|
||||
responseText: "已为 night-harbor 完成下一章。",
|
||||
messages: [
|
||||
{ role: "assistant", content: "已为 night-harbor 完成下一章。" },
|
||||
],
|
||||
});
|
||||
const { processTuiAgentInput } = await import("../tui/agent-input.js");
|
||||
const session = {
|
||||
...createProjectSession(projectRoot),
|
||||
@@ -199,15 +202,22 @@ describe("tui agent session bridge", () => {
|
||||
|
||||
const result = await processTuiAgentInput({
|
||||
projectRoot,
|
||||
input: "写第1章",
|
||||
input: "/write",
|
||||
session,
|
||||
});
|
||||
|
||||
expect(runAgentSessionMock).not.toHaveBeenCalled();
|
||||
expect(result.responseText).toContain("第 1 章");
|
||||
expect(result.responseText).toContain("雨夜");
|
||||
expect(runAgentSessionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bookId: "night-harbor",
|
||||
sessionKind: "book",
|
||||
actionSource: "slash",
|
||||
requestedIntent: "write_next",
|
||||
}),
|
||||
"/write",
|
||||
[],
|
||||
);
|
||||
expect(result.responseText).toContain("完成下一章");
|
||||
const persisted = await loadProjectSession(projectRoot);
|
||||
expect(persisted.activeBookId).toBe("night-harbor");
|
||||
expect(persisted.activeChapterNumber).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Command } from "commander";
|
||||
import { runAgentLoop } from "@actalk/inkos-core";
|
||||
import { loadConfig, createClient, findProjectRoot, resolveContext, log, logError } from "../utils.js";
|
||||
import { PipelineRunner, runAgentSession } from "@actalk/inkos-core";
|
||||
import { buildPipelineConfig, loadConfig, createClient, findProjectRoot, resolveBookId, resolveContext, log, logError } from "../utils.js";
|
||||
|
||||
export const agentCommand = new Command("agent")
|
||||
.description("Natural language agent mode (LLM orchestrates via tool-use)")
|
||||
.argument("<instruction>", "Natural language instruction")
|
||||
.option("--book <bookId>", "Bind this request to an existing book")
|
||||
.option("--session <sessionId>", "Reuse an agent session id")
|
||||
.option("--context <text>", "Additional context (natural language)")
|
||||
.option("--context-file <path>", "Read additional context from file")
|
||||
.option("--max-turns <n>", "Maximum agent turns", "20")
|
||||
.option("--json", "Output JSON (suppress progress messages)")
|
||||
.option("--quiet", "Suppress tool call logs")
|
||||
.option("--quiet", "Suppress non-JSON console output")
|
||||
.action(async (instruction: string, opts) => {
|
||||
try {
|
||||
const config = await loadConfig();
|
||||
@@ -21,38 +22,47 @@ export const agentCommand = new Command("agent")
|
||||
? `${instruction}\n\n补充信息:${context}`
|
||||
: instruction;
|
||||
|
||||
const maxTurns = parseInt(opts.maxTurns, 10);
|
||||
const bookId = opts.book ? await resolveBookId(opts.book, root) : null;
|
||||
const trimmed = fullInstruction.trim();
|
||||
const actionSource = trimmed.startsWith("/") ? "slash" : "free-text";
|
||||
const requestedIntent = bookId && trimmed === "/write"
|
||||
? "write_next"
|
||||
: !bookId && trimmed === "/create"
|
||||
? "create_book"
|
||||
: undefined;
|
||||
const sessionKind = bookId
|
||||
? "book"
|
||||
: requestedIntent === "create_book"
|
||||
? "book-create"
|
||||
: "chat";
|
||||
const sessionId = opts.session?.trim() || `cli-agent-${Date.now().toString(36)}`;
|
||||
const pipeline = new PipelineRunner(buildPipelineConfig(config, root, {
|
||||
externalContext: context,
|
||||
quiet: opts.quiet || opts.json,
|
||||
}));
|
||||
|
||||
const result = await runAgentLoop(
|
||||
const result = await runAgentSession(
|
||||
{
|
||||
client,
|
||||
model: config.llm.model,
|
||||
sessionId,
|
||||
bookId,
|
||||
sessionKind,
|
||||
actionSource,
|
||||
requestedIntent,
|
||||
language: config.language ?? "zh",
|
||||
pipeline,
|
||||
projectRoot: root,
|
||||
model: client._piModel
|
||||
? client._piModel
|
||||
: { provider: config.llm.provider ?? "openai", modelId: config.llm.model },
|
||||
apiKey: client._apiKey,
|
||||
},
|
||||
fullInstruction,
|
||||
{
|
||||
maxTurns,
|
||||
onToolCall: opts.quiet || opts.json
|
||||
? undefined
|
||||
: (name, args) => {
|
||||
log(` [tool] ${name}(${JSON.stringify(args)})`);
|
||||
},
|
||||
onToolResult: opts.quiet || opts.json
|
||||
? undefined
|
||||
: (name, result) => {
|
||||
const preview = result.length > 200 ? `${result.slice(0, 200)}...` : result;
|
||||
log(` [result] ${name} → ${preview}`);
|
||||
},
|
||||
onMessage: opts.json
|
||||
? undefined
|
||||
: (content) => {
|
||||
log(`\n${content}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
log(JSON.stringify({ result }));
|
||||
} else if (!opts.quiet && result.responseText.trim()) {
|
||||
log(result.responseText);
|
||||
}
|
||||
} catch (e) {
|
||||
if (opts.json) {
|
||||
|
||||
@@ -1,29 +1,11 @@
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
processProjectInteractionInput,
|
||||
type InteractionRuntimeTools,
|
||||
PipelineRunner,
|
||||
runAgentSession,
|
||||
} from "@actalk/inkos-core";
|
||||
import { createInteractionTools } from "../interaction/tools.js";
|
||||
import { buildPipelineConfig, createClient, findProjectRoot, loadConfig } from "../utils.js";
|
||||
|
||||
export interface InteractCommandHooks {
|
||||
readonly runInteraction?: (params: {
|
||||
readonly projectRoot: string;
|
||||
readonly input: string;
|
||||
readonly activeBookId?: string;
|
||||
readonly tools: InteractionRuntimeTools;
|
||||
}) => Promise<{
|
||||
readonly request: unknown;
|
||||
readonly responseText?: string;
|
||||
readonly session: {
|
||||
readonly automationMode: string;
|
||||
readonly activeBookId?: string;
|
||||
readonly currentExecution?: unknown;
|
||||
readonly pendingDecision?: unknown;
|
||||
readonly messages: ReadonlyArray<unknown>;
|
||||
readonly events: ReadonlyArray<unknown>;
|
||||
};
|
||||
}>;
|
||||
readonly createTools?: (projectRoot: string) => Promise<InteractionRuntimeTools>;
|
||||
readonly readInput?: () => Promise<string>;
|
||||
}
|
||||
|
||||
@@ -65,45 +47,69 @@ async function readInteractionInput(
|
||||
|
||||
export function createInteractCommand(hooks: InteractCommandHooks = {}): Command {
|
||||
return new Command("interact")
|
||||
.description("Run a shared natural-language interaction against the current project")
|
||||
.description("Run a natural-language agent interaction against the current project")
|
||||
.argument("[message...]", "Natural-language message")
|
||||
.option("--message <text>", "Explicit natural-language message")
|
||||
.option("--book <bookId>", "Bind a specific active book for this interaction")
|
||||
.option("--session <sessionId>", "Reuse an agent session id")
|
||||
.option("--json", "Emit structured JSON for external agents")
|
||||
.action(async (messageArgs: ReadonlyArray<string>, opts) => {
|
||||
const input = await readInteractionInput(messageArgs, opts.message, hooks.readInput);
|
||||
const projectRoot = process.cwd();
|
||||
const tools = hooks.createTools
|
||||
? await hooks.createTools(projectRoot)
|
||||
: hooks.runInteraction
|
||||
? ({} as InteractionRuntimeTools)
|
||||
: await createInteractionTools(projectRoot, undefined, { requireApiKey: false });
|
||||
const runInteraction = hooks.runInteraction ?? processProjectInteractionInput;
|
||||
const result = await runInteraction({
|
||||
const projectRoot = findProjectRoot();
|
||||
const config = await loadConfig({ requireApiKey: false, projectRoot });
|
||||
const client = createClient(config);
|
||||
const bookId = typeof opts.book === "string" && opts.book.trim() ? opts.book.trim() : null;
|
||||
const trimmed = input.trim();
|
||||
const actionSource = trimmed.startsWith("/") ? "slash" : "free-text";
|
||||
const requestedIntent = bookId && trimmed === "/write"
|
||||
? "write_next"
|
||||
: !bookId && trimmed === "/create"
|
||||
? "create_book"
|
||||
: undefined;
|
||||
const sessionKind = bookId
|
||||
? "book"
|
||||
: requestedIntent === "create_book"
|
||||
? "book-create"
|
||||
: "chat";
|
||||
const sessionId = typeof opts.session === "string" && opts.session.trim()
|
||||
? opts.session.trim()
|
||||
: `cli-interact-${Date.now().toString(36)}`;
|
||||
const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot, {
|
||||
quiet: opts.json,
|
||||
}));
|
||||
|
||||
const result = await runAgentSession({
|
||||
sessionId,
|
||||
bookId,
|
||||
sessionKind,
|
||||
actionSource,
|
||||
requestedIntent,
|
||||
language: config.language ?? "zh",
|
||||
pipeline,
|
||||
projectRoot,
|
||||
input,
|
||||
activeBookId: opts.book,
|
||||
tools,
|
||||
});
|
||||
model: client._piModel
|
||||
? client._piModel
|
||||
: { provider: config.llm.provider ?? "openai", modelId: config.llm.model },
|
||||
apiKey: client._apiKey,
|
||||
}, input);
|
||||
|
||||
const responseText = result.responseText;
|
||||
const session = {
|
||||
sessionId,
|
||||
sessionKind,
|
||||
activeBookId: bookId ?? undefined,
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
request: result.request,
|
||||
responseText: result.responseText,
|
||||
session: result.session,
|
||||
currentExecution: result.session.currentExecution ?? null,
|
||||
pendingDecision: result.session.pendingDecision ?? null,
|
||||
events: result.session.events,
|
||||
responseText,
|
||||
session,
|
||||
}, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = result.responseText
|
||||
?? (result.session.messages.at(-1) && "content" in (result.session.messages.at(-1) as Record<string, unknown>)
|
||||
? String((result.session.messages.at(-1) as Record<string, unknown>).content)
|
||||
: "");
|
||||
if (text) {
|
||||
process.stdout.write(`${text}\n`);
|
||||
if (responseText) {
|
||||
process.stdout.write(`${responseText}\n`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
PipelineRunner,
|
||||
StateManager,
|
||||
createInteractionToolsFromDeps,
|
||||
type InteractionRuntimeTools,
|
||||
} from "@actalk/inkos-core";
|
||||
import { buildPipelineConfig, loadConfig } from "../utils.js";
|
||||
|
||||
type CliPipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
|
||||
type CliStateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
|
||||
type CliInteractionToolHooks = {
|
||||
readonly onChatTextDelta?: (text: string) => void;
|
||||
readonly onDraftTextDelta?: (text: string) => void;
|
||||
readonly getChatRequestOptions?: () => {
|
||||
readonly temperature?: number;
|
||||
readonly maxTokens?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function createCliInteractionToolsFromDeps(
|
||||
pipeline: CliPipelineLike,
|
||||
state: CliStateLike,
|
||||
hooks?: CliInteractionToolHooks,
|
||||
): InteractionRuntimeTools {
|
||||
return createInteractionToolsFromDeps(pipeline, state, hooks);
|
||||
}
|
||||
|
||||
// Backward-compatible export for existing CLI interaction tests.
|
||||
export function createInteractionToolsFromDepsCompat(
|
||||
_projectRoot: string,
|
||||
pipeline: CliPipelineLike,
|
||||
state: CliStateLike,
|
||||
hooks?: CliInteractionToolHooks,
|
||||
): InteractionRuntimeTools {
|
||||
return createInteractionToolsFromDeps(pipeline, state, hooks);
|
||||
}
|
||||
|
||||
export { createInteractionToolsFromDepsCompat as createInteractionToolsFromDeps };
|
||||
|
||||
export async function createInteractionTools(
|
||||
projectRoot: string,
|
||||
hooks?: CliInteractionToolHooks,
|
||||
options?: { readonly requireApiKey?: boolean },
|
||||
): Promise<InteractionRuntimeTools> {
|
||||
const config = await loadConfig({ projectRoot, requireApiKey: options?.requireApiKey });
|
||||
const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot));
|
||||
const state = new StateManager(projectRoot);
|
||||
return createInteractionToolsFromDeps(pipeline, state, hooks);
|
||||
}
|
||||
@@ -37,7 +37,6 @@ const { version } = require("../package.json") as { version: string };
|
||||
export interface ProgramHooks {
|
||||
readonly launchTui?: (projectRoot: string) => Promise<void> | void;
|
||||
readonly launchStudio?: (projectRoot: string, port: string) => Promise<void> | void;
|
||||
readonly runInteraction?: InteractCommandHooks["runInteraction"];
|
||||
readonly readInteractionInput?: InteractCommandHooks["readInput"];
|
||||
}
|
||||
|
||||
@@ -89,7 +88,6 @@ export function createProgram(hooks: ProgramHooks = {}): Command {
|
||||
program.addCommand(createStudioCommand({ launchStudio: hooks.launchStudio }));
|
||||
program.addCommand(consolidateCommand);
|
||||
program.addCommand(createInteractCommand({
|
||||
runInteraction: hooks.runInteraction,
|
||||
readInput: hooks.readInteractionInput,
|
||||
}));
|
||||
program.addCommand(createTuiCommand({ launchTui: hooks.launchTui }));
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
appendInteractionMessage,
|
||||
clearPendingDecision,
|
||||
createLLMClient,
|
||||
isWriteNextInstruction,
|
||||
runAgentSession,
|
||||
type InteractionSession,
|
||||
} from "@actalk/inkos-core";
|
||||
@@ -42,75 +41,21 @@ export async function processTuiAgentInput(params: {
|
||||
timestamp: userTimestamp,
|
||||
});
|
||||
|
||||
if (!resolvedBookId && isCreateBookInstruction(params.input)) {
|
||||
const book = parseBookCreationRequest(params.input);
|
||||
if (book) {
|
||||
await pipeline.initBook(book, {
|
||||
externalContext: params.input,
|
||||
authorIntent: params.input,
|
||||
});
|
||||
const responseText = `已创建《${book.title}》,接下来可以直接输入“写第1章”。`;
|
||||
nextSession = appendInteractionMessage({
|
||||
...nextSession,
|
||||
activeBookId: book.id,
|
||||
currentExecution: {
|
||||
status: "completed",
|
||||
bookId: book.id,
|
||||
stageLabel: "architect",
|
||||
},
|
||||
}, {
|
||||
role: "assistant",
|
||||
content: responseText,
|
||||
timestamp: userTimestamp + 1,
|
||||
});
|
||||
await persistProjectSession(params.projectRoot, nextSession);
|
||||
return {
|
||||
responseText,
|
||||
session: nextSession,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedBookId && isWriteNextInstruction(params.input, { allowSlashWrite: true })) {
|
||||
const writeResult = await pipeline.writeNextChapter(resolvedBookId);
|
||||
const chapterNumber = getResultNumber(writeResult, "chapterNumber");
|
||||
const title = getResultString(writeResult, "title");
|
||||
const wordCount = getResultNumber(writeResult, "wordCount");
|
||||
const status = getResultString(writeResult, "status");
|
||||
const responseText = [
|
||||
`已为 ${resolvedBookId} 完成`,
|
||||
chapterNumber ? `第 ${chapterNumber} 章` : "下一章",
|
||||
title ? `《${title}》` : "",
|
||||
wordCount ? `,字数 ${wordCount}` : "",
|
||||
status ? `,状态 ${status}` : "",
|
||||
"。",
|
||||
].join("");
|
||||
nextSession = appendInteractionMessage({
|
||||
...nextSession,
|
||||
activeBookId: resolvedBookId,
|
||||
currentExecution: {
|
||||
status: "completed",
|
||||
bookId: resolvedBookId,
|
||||
...(chapterNumber ? { chapterNumber } : {}),
|
||||
stageLabel: "writer",
|
||||
},
|
||||
...(chapterNumber ? { activeChapterNumber: chapterNumber } : {}),
|
||||
}, {
|
||||
role: "assistant",
|
||||
content: responseText,
|
||||
timestamp: userTimestamp + 1,
|
||||
});
|
||||
await persistProjectSession(params.projectRoot, nextSession);
|
||||
return {
|
||||
responseText,
|
||||
session: nextSession,
|
||||
};
|
||||
}
|
||||
const trimmedInput = params.input.trim();
|
||||
const actionSource = trimmedInput.startsWith("/") ? "slash" : "free-text";
|
||||
const requestedIntent = resolvedBookId && trimmedInput === "/write"
|
||||
? "write_next"
|
||||
: !resolvedBookId && trimmedInput === "/create"
|
||||
? "create_book"
|
||||
: undefined;
|
||||
|
||||
const result = await runAgentSession(
|
||||
{
|
||||
sessionId: params.session.sessionId,
|
||||
bookId: resolvedBookId,
|
||||
sessionKind: resolvedBookId ? "book" : "book-create",
|
||||
actionSource,
|
||||
requestedIntent,
|
||||
language: config.language ?? "zh",
|
||||
pipeline,
|
||||
projectRoot: params.projectRoot,
|
||||
@@ -167,76 +112,6 @@ export async function processTuiAgentInput(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function isCreateBookInstruction(instruction: string): boolean {
|
||||
return /^\/new\s+/i.test(instruction)
|
||||
&& /(?:标题|书名|title)\s*[《"“]/i.test(instruction);
|
||||
}
|
||||
|
||||
function parseBookCreationRequest(instruction: string): {
|
||||
id: string;
|
||||
title: string;
|
||||
genre: string;
|
||||
platform: "tomato" | "feilu" | "qidian" | "other";
|
||||
language: "zh" | "en";
|
||||
status: "outlining";
|
||||
targetChapters: number;
|
||||
chapterWordCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
} | undefined {
|
||||
const title = extractTitle(instruction);
|
||||
if (!title) return undefined;
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: deriveBookId(title),
|
||||
title,
|
||||
genre: inferGenre(instruction),
|
||||
platform: inferPlatform(instruction),
|
||||
language: /[\u4e00-\u9fff]/.test(instruction) ? "zh" : "en",
|
||||
status: "outlining",
|
||||
targetChapters: extractNumber(instruction, /(\d+)\s*章/) ?? 200,
|
||||
chapterWordCount: extractNumber(instruction, /(?:每章|章节|章)\D{0,8}(\d{3,5})\s*字/) ?? 3000,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function extractTitle(instruction: string): string | undefined {
|
||||
const match = instruction.match(/(?:标题|书名|title)\s*[《"“]([^》"”]+)[》"”]/i);
|
||||
const title = match?.[1]?.trim();
|
||||
return title && title.length > 0 ? title : undefined;
|
||||
}
|
||||
|
||||
function extractNumber(instruction: string, pattern: RegExp): number | undefined {
|
||||
const value = Number.parseInt(instruction.match(pattern)?.[1] ?? "", 10);
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function inferPlatform(instruction: string): "tomato" | "feilu" | "qidian" | "other" {
|
||||
if (/番茄|tomato/i.test(instruction)) return "tomato";
|
||||
if (/飞卢|feilu/i.test(instruction)) return "feilu";
|
||||
if (/起点|qidian/i.test(instruction)) return "qidian";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function inferGenre(instruction: string): string {
|
||||
if (/都市/.test(instruction)) return "urban";
|
||||
if (/悬疑|推理|mystery/i.test(instruction)) return "mystery";
|
||||
if (/玄幻|xuanhuan/i.test(instruction)) return "xuanhuan";
|
||||
if (/科幻|sci[-\s]?fi/i.test(instruction)) return "sci-fi";
|
||||
if (/言情|甜宠|romance/i.test(instruction)) return "romance";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function deriveBookId(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 30) || `book-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
function extractCreatedBookId(messages: ReadonlyArray<unknown>): string | undefined {
|
||||
for (const message of messages) {
|
||||
const details = (message as { details?: { kind?: string; bookId?: string } }).details;
|
||||
@@ -246,13 +121,3 @@ function extractCreatedBookId(messages: ReadonlyArray<unknown>): string | undefi
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getResultString(value: unknown, key: string): string | undefined {
|
||||
const raw = (value as Record<string, unknown>)[key];
|
||||
return typeof raw === "string" && raw.length > 0 ? raw : undefined;
|
||||
}
|
||||
|
||||
function getResultNumber(value: unknown, key: string): number | undefined {
|
||||
const raw = (value as Record<string, unknown>)[key];
|
||||
return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
|
||||
}
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createInteractionToolsFromDeps } from "../interaction/project-tools.js";
|
||||
|
||||
const mockChatCompletion = vi.hoisted(() => vi.fn());
|
||||
const mockChatWithTools = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../index.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, chatCompletion: mockChatCompletion, chatWithTools: mockChatWithTools };
|
||||
});
|
||||
|
||||
const fakePipeline = {
|
||||
config: {
|
||||
client: {} as object,
|
||||
model: "gpt-4o",
|
||||
},
|
||||
writeNextChapter: vi.fn(),
|
||||
reviseDraft: vi.fn(),
|
||||
};
|
||||
|
||||
const fakeState = {
|
||||
ensureControlDocuments: vi.fn(async () => {}),
|
||||
bookDir: vi.fn(() => "/tmp/books/test"),
|
||||
loadBookConfig: vi.fn(async () => undefined),
|
||||
loadChapterIndex: vi.fn(async () => []),
|
||||
saveChapterIndex: vi.fn(async () => undefined),
|
||||
listBooks: vi.fn(async () => []),
|
||||
};
|
||||
|
||||
const MOCK_CHAT_RESPONSE = {
|
||||
content: [
|
||||
"好的,你想写都市异能,请问主角是什么类型的能力?",
|
||||
"",
|
||||
':::field{key="title" label="书名"}',
|
||||
"都市异能",
|
||||
":::",
|
||||
].join("\n"),
|
||||
tokensUsed: { prompt: 5, completion: 80, total: 85 },
|
||||
};
|
||||
|
||||
const MOCK_TOOL_RESPONSE = {
|
||||
content: "好的,已根据你的描述生成建书参数。",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call_1",
|
||||
name: "create_book",
|
||||
arguments: JSON.stringify({
|
||||
title: "都市异能",
|
||||
genre: "urban",
|
||||
platform: "tomato",
|
||||
targetChapters: 160,
|
||||
chapterWordCount: 2800,
|
||||
brief: "都市异能题材",
|
||||
worldPremise: "旧城区被异能公司分区管理,普通人靠通行证活着。",
|
||||
protagonist: "陈野,外卖员,能看见别人欠下的代价。",
|
||||
conflictCore: "主角想保住妹妹,却被公司逼成黑市清账人。",
|
||||
volumeOutline: "卷一先查妹妹病历,再掀出公司清账规则。",
|
||||
nextQuestion: "主角的异能代价要不要更重?",
|
||||
missingFields: ["supportingCast"],
|
||||
readyToCreate: false,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("chat tool – maxTokens forwarding", () => {
|
||||
beforeEach(() => {
|
||||
mockChatCompletion.mockResolvedValue({
|
||||
content: "Hello",
|
||||
tokensUsed: { prompt: 5, completion: 10, total: 15 },
|
||||
});
|
||||
mockChatCompletion.mockClear();
|
||||
});
|
||||
|
||||
it("does not pass maxTokens to chatCompletion when depth has no maxTokens set", async () => {
|
||||
const tools = createInteractionToolsFromDeps(
|
||||
fakePipeline as never,
|
||||
fakeState as never,
|
||||
{
|
||||
getChatRequestOptions: () => ({ temperature: 0.7 }),
|
||||
},
|
||||
);
|
||||
|
||||
await tools.chat?.("你好", { bookId: "test-book", automationMode: "manual" });
|
||||
|
||||
expect(mockChatCompletion).toHaveBeenCalledOnce();
|
||||
const options = mockChatCompletion.mock.calls[0]?.[3] as Record<string, unknown> | undefined;
|
||||
expect(options).not.toHaveProperty("maxTokens");
|
||||
});
|
||||
|
||||
it("passes maxTokens to chatCompletion when depth explicitly sets it", async () => {
|
||||
const tools = createInteractionToolsFromDeps(
|
||||
fakePipeline as never,
|
||||
fakeState as never,
|
||||
{
|
||||
getChatRequestOptions: () => ({ temperature: 0.7, maxTokens: 512 }),
|
||||
},
|
||||
);
|
||||
|
||||
await tools.chat?.("你好", { bookId: "test-book", automationMode: "manual" });
|
||||
|
||||
expect(mockChatCompletion).toHaveBeenCalledOnce();
|
||||
const options = mockChatCompletion.mock.calls[0]?.[3] as Record<string, unknown> | undefined;
|
||||
expect(options).toHaveProperty("maxTokens", 512);
|
||||
});
|
||||
|
||||
it("rethrows real chatCompletion errors instead of silently falling back", async () => {
|
||||
mockChatCompletion.mockRejectedValueOnce(new Error("provider down"));
|
||||
|
||||
const tools = createInteractionToolsFromDeps(
|
||||
fakePipeline as never,
|
||||
fakeState as never,
|
||||
{
|
||||
getChatRequestOptions: () => ({ temperature: 0.7 }),
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
tools.chat?.("你好", { bookId: "test-book", automationMode: "manual" }),
|
||||
).rejects.toThrow("provider down");
|
||||
});
|
||||
});
|
||||
|
||||
describe("developBookDraft – uses chatWithTools", () => {
|
||||
beforeEach(() => {
|
||||
mockChatWithTools.mockResolvedValue(MOCK_TOOL_RESPONSE);
|
||||
mockChatWithTools.mockClear();
|
||||
});
|
||||
|
||||
it("calls chatWithTools with create_book tool and does not pass maxTokens", async () => {
|
||||
const tools = createInteractionToolsFromDeps(
|
||||
fakePipeline as never,
|
||||
fakeState as never,
|
||||
);
|
||||
|
||||
await tools.developBookDraft?.("我想写都市异能", undefined);
|
||||
|
||||
expect(mockChatWithTools).toHaveBeenCalledOnce();
|
||||
const options = mockChatWithTools.mock.calls[0]?.[4] as Record<string, unknown> | undefined;
|
||||
expect(options).not.toHaveProperty("maxTokens");
|
||||
});
|
||||
|
||||
it("extracts tool call arguments into the creation draft", async () => {
|
||||
const tools = createInteractionToolsFromDeps(
|
||||
fakePipeline as never,
|
||||
fakeState as never,
|
||||
);
|
||||
|
||||
const result = await tools.developBookDraft?.("我想写都市异能", undefined) as Record<string, unknown>;
|
||||
const interaction = (result as { __interaction: Record<string, unknown> }).__interaction;
|
||||
const details = interaction.details as Record<string, unknown>;
|
||||
|
||||
expect(details.creationDraft).toEqual(expect.objectContaining({
|
||||
title: "都市异能",
|
||||
genre: "urban",
|
||||
platform: "tomato",
|
||||
targetChapters: 160,
|
||||
chapterWordCount: 2800,
|
||||
blurb: "都市异能题材",
|
||||
worldPremise: "旧城区被异能公司分区管理,普通人靠通行证活着。",
|
||||
protagonist: "陈野,外卖员,能看见别人欠下的代价。",
|
||||
conflictCore: "主角想保住妹妹,却被公司逼成黑市清账人。",
|
||||
volumeOutline: "卷一先查妹妹病历,再掀出公司清账规则。",
|
||||
nextQuestion: "主角的异能代价要不要更重?",
|
||||
missingFields: ["supportingCast"],
|
||||
readyToCreate: false,
|
||||
}));
|
||||
expect(details.toolCall).toEqual({
|
||||
name: "create_book",
|
||||
arguments: {
|
||||
title: "都市异能",
|
||||
genre: "urban",
|
||||
platform: "tomato",
|
||||
targetChapters: 160,
|
||||
chapterWordCount: 2800,
|
||||
brief: "都市异能题材",
|
||||
worldPremise: "旧城区被异能公司分区管理,普通人靠通行证活着。",
|
||||
protagonist: "陈野,外卖员,能看见别人欠下的代价。",
|
||||
conflictCore: "主角想保住妹妹,却被公司逼成黑市清账人。",
|
||||
volumeOutline: "卷一先查妹妹病历,再掀出公司清账规则。",
|
||||
nextQuestion: "主角的异能代价要不要更重?",
|
||||
missingFields: ["supportingCast"],
|
||||
readyToCreate: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fills editable length defaults and never blocks creation on missing length", async () => {
|
||||
// The LLM returns the six story-core fields but no length — length is a run
|
||||
// parameter, so the draft should default to 200/3000 and still be ready.
|
||||
mockChatWithTools.mockResolvedValueOnce({
|
||||
content: "已生成草案。",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call_2",
|
||||
name: "create_book",
|
||||
arguments: JSON.stringify({
|
||||
title: "夜港账本",
|
||||
genre: "urban",
|
||||
platform: "tomato",
|
||||
worldPremise: "近未来港口城,账本牵出多方势力。",
|
||||
protagonist: "林砚,水货账房出身。",
|
||||
conflictCore: "洗白与旧债回潮的对撞。",
|
||||
readyToCreate: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tools = createInteractionToolsFromDeps(fakePipeline as never, fakeState as never);
|
||||
const result = await tools.developBookDraft?.("我想写港风商战", undefined) as Record<string, unknown>;
|
||||
const details = (result as { __interaction: { details: Record<string, unknown> } }).__interaction.details;
|
||||
const draft = details.creationDraft as Record<string, unknown>;
|
||||
|
||||
expect(draft.targetChapters).toBe(200);
|
||||
expect(draft.chapterWordCount).toBe(3000);
|
||||
expect(draft.readyToCreate).toBe(true);
|
||||
expect(draft.missingFields).not.toContain("targetChapters");
|
||||
expect(draft.missingFields).not.toContain("chapterWordCount");
|
||||
});
|
||||
|
||||
it("keeps a draft NOT ready while a story-core field (worldPremise) is missing, even with length defaulted", async () => {
|
||||
mockChatWithTools.mockResolvedValueOnce({
|
||||
content: "还差世界观。",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call_3",
|
||||
name: "create_book",
|
||||
arguments: JSON.stringify({
|
||||
title: "夜港账本",
|
||||
genre: "urban",
|
||||
platform: "tomato",
|
||||
protagonist: "林砚。",
|
||||
conflictCore: "洗白与旧债回潮的对撞。",
|
||||
readyToCreate: true, // LLM over-claims; deterministic gate must override
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tools = createInteractionToolsFromDeps(fakePipeline as never, fakeState as never);
|
||||
const result = await tools.developBookDraft?.("我想写港风商战", undefined) as Record<string, unknown>;
|
||||
const details = (result as { __interaction: { details: Record<string, unknown> } }).__interaction.details;
|
||||
const draft = details.creationDraft as Record<string, unknown>;
|
||||
|
||||
expect(draft.targetChapters).toBe(200);
|
||||
expect(draft.readyToCreate).toBe(false);
|
||||
expect(draft.missingFields).toContain("worldPremise");
|
||||
});
|
||||
|
||||
it("returns fallback when no LLM is configured", async () => {
|
||||
const noLlmPipeline = {
|
||||
config: {},
|
||||
writeNextChapter: vi.fn(),
|
||||
reviseDraft: vi.fn(),
|
||||
};
|
||||
|
||||
const tools = createInteractionToolsFromDeps(
|
||||
noLlmPipeline as never,
|
||||
fakeState as never,
|
||||
);
|
||||
|
||||
const result = await tools.developBookDraft?.("我想写都市异能", undefined) as Record<string, unknown>;
|
||||
const interaction = (result as { __interaction: Record<string, unknown> }).__interaction;
|
||||
|
||||
expect(mockChatWithTools).not.toHaveBeenCalled();
|
||||
expect(interaction.responseText).toContain("请先配置 LLM 模型");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import { runInteractionRequest } from "../interaction/runtime.js";
|
||||
function makeTools(overrides: Partial<Parameters<typeof runInteractionRequest>[0]["tools"]> = {}) {
|
||||
return {
|
||||
listBooks: vi.fn(async () => ["harbor"]),
|
||||
developBookDraft: vi.fn(),
|
||||
createBook: vi.fn(),
|
||||
exportBook: vi.fn(),
|
||||
writeNextChapter: vi.fn(),
|
||||
@@ -20,48 +19,6 @@ function makeTools(overrides: Partial<Parameters<typeof runInteractionRequest>[0
|
||||
}
|
||||
|
||||
describe("interaction runtime", () => {
|
||||
it("routes develop_book through the shared draft tool and updates the creation draft", async () => {
|
||||
const developBookDraft = vi.fn(async () => ({
|
||||
__interaction: {
|
||||
responseText: "我先按港风商战悬疑收着。你更想写长篇连载,还是十来章能收住?",
|
||||
details: {
|
||||
creationDraft: {
|
||||
concept: "港风商战悬疑,主角从灰产洗白。",
|
||||
title: "夜港账本",
|
||||
genre: "urban",
|
||||
nextQuestion: "更想写长篇连载,还是十来章能收住?",
|
||||
missingFields: ["targetChapters"],
|
||||
readyToCreate: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await runInteractionRequest({
|
||||
session: InteractionSessionSchema.parse({
|
||||
sessionId: "session-draft",
|
||||
projectRoot: "/tmp/project",
|
||||
automationMode: "semi",
|
||||
messages: [],
|
||||
events: [],
|
||||
}),
|
||||
request: {
|
||||
intent: "develop_book",
|
||||
instruction: "我想写个港风商战悬疑,主角从灰产洗白。",
|
||||
},
|
||||
tools: makeTools({
|
||||
developBookDraft,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(developBookDraft).toHaveBeenCalledWith("我想写个港风商战悬疑,主角从灰产洗白。", undefined);
|
||||
expect(result.session.creationDraft).toEqual(expect.objectContaining({
|
||||
title: "夜港账本",
|
||||
genre: "urban",
|
||||
}));
|
||||
expect(result.responseText).toContain("港风商战悬疑");
|
||||
});
|
||||
|
||||
it("routes create_book through the shared create tool and binds the created book", async () => {
|
||||
const createBook = vi.fn(async () => ({
|
||||
bookId: "night-harbor",
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AGENT_TOOLS, executeAgentTool } from "../pipeline/agent.js";
|
||||
import { PipelineRunner, StateManager, type PipelineConfig } from "../index.js";
|
||||
import { PlannerAgent } from "../agents/planner.js";
|
||||
|
||||
describe("agent pipeline tools", () => {
|
||||
let root: string;
|
||||
let state: StateManager;
|
||||
let pipeline: PipelineRunner;
|
||||
let config: PipelineConfig;
|
||||
let bookId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "inkos-agent-tools-"));
|
||||
state = new StateManager(root);
|
||||
bookId = "agent-book";
|
||||
|
||||
config = {
|
||||
client: {
|
||||
provider: "openai",
|
||||
apiFormat: "chat",
|
||||
stream: false,
|
||||
defaults: {
|
||||
temperature: 0.7,
|
||||
maxTokens: 4096,
|
||||
thinkingBudget: 0,
|
||||
extra: {},
|
||||
},
|
||||
},
|
||||
model: "test-model",
|
||||
projectRoot: root,
|
||||
inputGovernanceMode: "v2",
|
||||
};
|
||||
|
||||
pipeline = new PipelineRunner(config);
|
||||
|
||||
await state.saveBookConfig(bookId, {
|
||||
id: bookId,
|
||||
title: "Agent Book",
|
||||
platform: "tomato",
|
||||
genre: "other",
|
||||
status: "active",
|
||||
targetChapters: 20,
|
||||
chapterWordCount: 3000,
|
||||
createdAt: "2026-03-22T00:00:00.000Z",
|
||||
updatedAt: "2026-03-22T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const storyDir = join(state.bookDir(bookId), "story");
|
||||
await mkdir(join(storyDir, "runtime"), { recursive: true });
|
||||
await mkdir(join(state.bookDir(bookId), "chapters"), { recursive: true });
|
||||
await writeFile(join(state.bookDir(bookId), "chapters", "index.json"), "[]", "utf-8");
|
||||
|
||||
vi.spyOn(PlannerAgent.prototype, "planChapter").mockImplementation(async (input) => {
|
||||
const chapterNumber = input.chapterNumber;
|
||||
// Try to read the local override from current_focus.md, mirroring the real planner logic
|
||||
let goal = input.externalContext ?? "test goal";
|
||||
try {
|
||||
const { readFile: readFs } = await import("node:fs/promises");
|
||||
const focusContent = await readFs(join(input.bookDir, "story", "current_focus.md"), "utf-8");
|
||||
const overrideMatch = focusContent.match(/## Local Override\s*\n+([^\n#]+)/);
|
||||
if (overrideMatch?.[1]?.trim()) {
|
||||
goal = overrideMatch[1].trim();
|
||||
}
|
||||
} catch { /* ignore missing file */ }
|
||||
const memo = {
|
||||
chapter: chapterNumber,
|
||||
goal,
|
||||
isGoldenOpening: false,
|
||||
body: "",
|
||||
threadRefs: [] as string[],
|
||||
};
|
||||
const intentMarkdown = [
|
||||
"# Chapter Intent",
|
||||
"",
|
||||
"## Goal",
|
||||
goal,
|
||||
].join("\n");
|
||||
const { mkdir: mkdirFs, writeFile: writeFileFs } = await import("node:fs/promises");
|
||||
const runtimeDir = join(input.bookDir, "story", "runtime");
|
||||
await mkdirFs(runtimeDir, { recursive: true });
|
||||
const runtimePath = join(runtimeDir, `chapter-${String(chapterNumber).padStart(4, "0")}.intent.md`);
|
||||
await writeFileFs(runtimePath, intentMarkdown, "utf-8");
|
||||
return {
|
||||
intent: {
|
||||
chapter: chapterNumber,
|
||||
goal,
|
||||
mustKeep: [],
|
||||
mustAvoid: [],
|
||||
styleEmphasis: [],
|
||||
},
|
||||
memo,
|
||||
intentMarkdown,
|
||||
plannerInputs: [runtimePath],
|
||||
runtimePath,
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nKeep the story centered on the mentor conflict.\n", "utf-8"),
|
||||
writeFile(join(storyDir, "current_focus.md"), "# Current Focus\n\nBring focus back to the mentor conflict.\n", "utf-8"),
|
||||
writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
|
||||
writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 1\nTrack the merchant guild trail.\n", "utf-8"),
|
||||
writeFile(join(storyDir, "book_rules.md"), "---\nprohibitions:\n - Do not reveal the mastermind\n---\n\n# Book Rules\n", "utf-8"),
|
||||
writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
|
||||
writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n\n- Why the mentor vanished after the trial.\n", "utf-8"),
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("registers the input governance tools", () => {
|
||||
const toolNames = AGENT_TOOLS.map((tool) => tool.name);
|
||||
|
||||
expect(toolNames).toContain("plan_chapter");
|
||||
expect(toolNames).toContain("compose_chapter");
|
||||
expect(toolNames).toContain("update_author_intent");
|
||||
expect(toolNames).toContain("update_current_focus");
|
||||
});
|
||||
|
||||
it("plans and composes chapters through the agent tool surface", async () => {
|
||||
const planResult = JSON.parse(await executeAgentTool(
|
||||
pipeline,
|
||||
state,
|
||||
config,
|
||||
"plan_chapter",
|
||||
{ bookId, guidance: "Ignore the guild chase and focus on the mentor conflict." },
|
||||
));
|
||||
|
||||
expect(planResult.intentPath).toBe("story/runtime/chapter-0001.intent.md");
|
||||
|
||||
const composeResult = JSON.parse(await executeAgentTool(
|
||||
pipeline,
|
||||
state,
|
||||
config,
|
||||
"compose_chapter",
|
||||
{ bookId, guidance: "Ignore the guild chase and focus on the mentor conflict." },
|
||||
));
|
||||
|
||||
expect(composeResult.contextPath).toBe("story/runtime/chapter-0001.context.json");
|
||||
expect(composeResult.ruleStackPath).toBe("story/runtime/chapter-0001.rule-stack.yaml");
|
||||
expect(composeResult.tracePath).toBe("story/runtime/chapter-0001.trace.json");
|
||||
});
|
||||
|
||||
it("updates author_intent.md and current_focus.md through dedicated tools", async () => {
|
||||
await executeAgentTool(pipeline, state, config, "update_author_intent", {
|
||||
bookId,
|
||||
content: "# Author Intent\n\nMake this a colder revenge story.\n",
|
||||
});
|
||||
await executeAgentTool(pipeline, state, config, "update_current_focus", {
|
||||
bookId,
|
||||
content: "# Current Focus\n\nSpend the next two chapters on mentor fallout.\n",
|
||||
});
|
||||
|
||||
await expect(readFile(join(state.bookDir(bookId), "story", "author_intent.md"), "utf-8"))
|
||||
.resolves.toContain("colder revenge story");
|
||||
await expect(readFile(join(state.bookDir(bookId), "story", "current_focus.md"), "utf-8"))
|
||||
.resolves.toContain("mentor fallout");
|
||||
});
|
||||
|
||||
it("normalizes human-facing platform aliases before create_book persists config", async () => {
|
||||
const initBook = vi.spyOn(PipelineRunner.prototype, "initBook").mockResolvedValue(undefined);
|
||||
|
||||
const result = JSON.parse(await executeAgentTool(
|
||||
pipeline,
|
||||
state,
|
||||
config,
|
||||
"create_book",
|
||||
{
|
||||
title: "测试书",
|
||||
genre: "urban",
|
||||
platform: "番茄小说",
|
||||
brief: "一本文娱爽文。",
|
||||
},
|
||||
));
|
||||
|
||||
expect(result).toMatchObject({ bookId: "测试书", title: "测试书", status: "created" });
|
||||
expect(initBook).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: "测试书",
|
||||
platform: "tomato",
|
||||
}));
|
||||
});
|
||||
|
||||
it("keeps update_current_focus usable for explicit local overrides through the tool surface", async () => {
|
||||
await executeAgentTool(pipeline, state, config, "update_current_focus", {
|
||||
bookId,
|
||||
content: [
|
||||
"# Current Focus",
|
||||
"",
|
||||
"## Active Focus",
|
||||
"",
|
||||
"Keep the merchant guild trail visible in the background.",
|
||||
"",
|
||||
"## Local Override",
|
||||
"",
|
||||
"Stay inside the mentor debt confrontation first and delay the guild chase by one chapter.",
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
const planResult = JSON.parse(await executeAgentTool(
|
||||
pipeline,
|
||||
state,
|
||||
config,
|
||||
"plan_chapter",
|
||||
{ bookId },
|
||||
));
|
||||
|
||||
const runtimePath = join(state.bookDir(bookId), planResult.intentPath);
|
||||
const intentMarkdown = await readFile(runtimePath, "utf-8");
|
||||
expect(intentMarkdown).toContain([
|
||||
"## Goal",
|
||||
"Stay inside the mentor debt confrontation first and delay the guild chase by one chapter.",
|
||||
].join("\n"));
|
||||
});
|
||||
|
||||
it("blocks write_full_pipeline when runtime progress is ahead of the chapter index", async () => {
|
||||
const chaptersDir = join(state.bookDir(bookId), "chapters");
|
||||
// Create durable chapter files for 1-3 but only index chapter 1.
|
||||
// This produces durableChapter=3, nextNum=4 while lastIndexedChapter=1,
|
||||
// triggering the sequential write guard.
|
||||
await state.saveChapterIndex(bookId, [{
|
||||
number: 1,
|
||||
title: "Existing Chapter",
|
||||
status: "approved",
|
||||
wordCount: 120,
|
||||
createdAt: "2026-03-22T00:00:00.000Z",
|
||||
updatedAt: "2026-03-22T00:00:00.000Z",
|
||||
auditIssues: [],
|
||||
lengthWarnings: [],
|
||||
}]);
|
||||
await Promise.all([
|
||||
writeFile(join(chaptersDir, "0001_Existing.md"), "# Chapter 1\n", "utf-8"),
|
||||
writeFile(join(chaptersDir, "0002_Second.md"), "# Chapter 2\n", "utf-8"),
|
||||
writeFile(join(chaptersDir, "0003_Third.md"), "# Chapter 3\n", "utf-8"),
|
||||
]);
|
||||
|
||||
const writeNextChapter = vi.spyOn(pipeline, "writeNextChapter").mockResolvedValue({
|
||||
bookId,
|
||||
chapterNumber: 4,
|
||||
title: "Should Not Run",
|
||||
wordCount: 100,
|
||||
filePath: "books/agent-book/chapters/0004_Should_Not_Run.md",
|
||||
auditResult: { passed: true, issues: [], summary: "ok" },
|
||||
revised: false,
|
||||
status: "ready-for-review",
|
||||
} as Awaited<ReturnType<typeof pipeline.writeNextChapter>>);
|
||||
|
||||
const result = JSON.parse(await executeAgentTool(
|
||||
pipeline,
|
||||
state,
|
||||
config,
|
||||
"write_full_pipeline",
|
||||
{ bookId, count: 1 },
|
||||
));
|
||||
|
||||
expect(result.error).toContain("write_full_pipeline");
|
||||
expect(writeNextChapter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks write_truth_file from hacking chapter progress inside current_state.md", async () => {
|
||||
const result = JSON.parse(await executeAgentTool(
|
||||
pipeline,
|
||||
state,
|
||||
config,
|
||||
"write_truth_file",
|
||||
{
|
||||
bookId,
|
||||
fileName: "current_state.md",
|
||||
content: "# Current State\n\n| Current Chapter | 999 |\n",
|
||||
},
|
||||
));
|
||||
|
||||
expect(result.error).toContain("章节进度");
|
||||
});
|
||||
|
||||
// Phase hotfix 3: write_truth_file must accept both Chinese and English
|
||||
// role-dir paths so English-layout books are writable, not just readable.
|
||||
it("accepts roles/主要角色/<name>.md (zh locale)", async () => {
|
||||
const result = JSON.parse(await executeAgentTool(
|
||||
pipeline,
|
||||
state,
|
||||
config,
|
||||
"write_truth_file",
|
||||
{
|
||||
bookId,
|
||||
fileName: "roles/主要角色/林辞.md",
|
||||
content: "# 林辞\n核心标签:沉默",
|
||||
},
|
||||
));
|
||||
expect(result.error).toBeUndefined();
|
||||
const written = await readFile(
|
||||
join(state.bookDir(bookId), "story", "roles/主要角色/林辞.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(written).toContain("核心标签");
|
||||
});
|
||||
|
||||
it("accepts roles/major/<name>.md (en locale)", async () => {
|
||||
const result = JSON.parse(await executeAgentTool(
|
||||
pipeline,
|
||||
state,
|
||||
config,
|
||||
"write_truth_file",
|
||||
{
|
||||
bookId,
|
||||
fileName: "roles/major/Mara.md",
|
||||
content: "# Mara\nCore tag: stoic",
|
||||
},
|
||||
));
|
||||
expect(result.error).toBeUndefined();
|
||||
const written = await readFile(
|
||||
join(state.bookDir(bookId), "story", "roles/major/Mara.md"),
|
||||
"utf-8",
|
||||
);
|
||||
expect(written).toContain("Core tag");
|
||||
});
|
||||
|
||||
it("accepts roles/minor/<name>.md (en locale)", async () => {
|
||||
const result = JSON.parse(await executeAgentTool(
|
||||
pipeline,
|
||||
state,
|
||||
config,
|
||||
"write_truth_file",
|
||||
{
|
||||
bookId,
|
||||
fileName: "roles/minor/Kit.md",
|
||||
content: "# Kit\nMinor ally",
|
||||
},
|
||||
));
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects unknown role tier dirs (path-traversal safety preserved)", async () => {
|
||||
const result = JSON.parse(await executeAgentTool(
|
||||
pipeline,
|
||||
state,
|
||||
config,
|
||||
"write_truth_file",
|
||||
{
|
||||
bookId,
|
||||
fileName: "roles/其他/X.md",
|
||||
content: "# X",
|
||||
},
|
||||
));
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
loadProjectSession,
|
||||
persistProjectSession,
|
||||
} from "../interaction/project-session-store.js";
|
||||
import {
|
||||
processProjectInteractionInput,
|
||||
processProjectInteractionRequest,
|
||||
} from "../interaction/project-control.js";
|
||||
import { processProjectInteractionRequest } from "../interaction/project-control.js";
|
||||
|
||||
let projectRoot: string;
|
||||
|
||||
@@ -25,117 +22,6 @@ describe("project interaction control", () => {
|
||||
// tmpdir cleanup omitted
|
||||
});
|
||||
|
||||
it("routes explicit write command through the persisted active book", async () => {
|
||||
await persistProjectSession(projectRoot, {
|
||||
...createProjectSession(projectRoot),
|
||||
activeBookId: "harbor",
|
||||
});
|
||||
|
||||
const tools = {
|
||||
listBooks: vi.fn(async () => ["harbor"]),
|
||||
writeNextChapter: vi.fn(async () => ({ ok: true })),
|
||||
reviseDraft: vi.fn(async () => ({ ok: true })),
|
||||
patchChapterText: vi.fn(async () => ({ ok: true })),
|
||||
renameEntity: vi.fn(async () => ({ ok: true })),
|
||||
updateCurrentFocus: vi.fn(async () => ({ ok: true })),
|
||||
updateAuthorIntent: vi.fn(async () => ({ ok: true })),
|
||||
writeTruthFile: vi.fn(async () => ({ ok: true })),
|
||||
};
|
||||
|
||||
const result = await processProjectInteractionInput({
|
||||
projectRoot,
|
||||
input: "/write",
|
||||
tools,
|
||||
});
|
||||
|
||||
expect(tools.writeNextChapter).toHaveBeenCalledWith("harbor");
|
||||
expect(result.session.activeBookId).toBe("harbor");
|
||||
expect(result.request.intent).toBe("write_next");
|
||||
expect(result.session.events.map((event) => event.kind)).toEqual([
|
||||
"task.started",
|
||||
"task.completed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists explicit mode switches in the project session", async () => {
|
||||
await persistProjectSession(projectRoot, {
|
||||
...createProjectSession(projectRoot),
|
||||
activeBookId: "harbor",
|
||||
});
|
||||
|
||||
const result = await processProjectInteractionInput({
|
||||
projectRoot,
|
||||
input: "/mode auto",
|
||||
tools: {
|
||||
listBooks: vi.fn(async () => ["harbor"]),
|
||||
writeNextChapter: vi.fn(async () => ({ ok: true })),
|
||||
reviseDraft: vi.fn(async () => ({ ok: true })),
|
||||
patchChapterText: vi.fn(async () => ({ ok: true })),
|
||||
renameEntity: vi.fn(async () => ({ ok: true })),
|
||||
updateCurrentFocus: vi.fn(async () => ({ ok: true })),
|
||||
updateAuthorIntent: vi.fn(async () => ({ ok: true })),
|
||||
writeTruthFile: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.session.automationMode).toBe("auto");
|
||||
expect(result.session.events.map((event) => event.kind)).toEqual([
|
||||
"task.started",
|
||||
"task.completed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists failed execution state when a routed action throws", async () => {
|
||||
await persistProjectSession(projectRoot, {
|
||||
...createProjectSession(projectRoot),
|
||||
activeBookId: "harbor",
|
||||
});
|
||||
|
||||
await expect(processProjectInteractionInput({
|
||||
projectRoot,
|
||||
input: "/write",
|
||||
tools: {
|
||||
listBooks: vi.fn(async () => ["harbor"]),
|
||||
writeNextChapter: vi.fn(async () => {
|
||||
throw new Error("boom");
|
||||
}),
|
||||
reviseDraft: vi.fn(async () => ({ ok: true })),
|
||||
patchChapterText: vi.fn(async () => ({ ok: true })),
|
||||
renameEntity: vi.fn(async () => ({ ok: true })),
|
||||
updateCurrentFocus: vi.fn(async () => ({ ok: true })),
|
||||
updateAuthorIntent: vi.fn(async () => ({ ok: true })),
|
||||
writeTruthFile: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
})).rejects.toThrow("boom");
|
||||
|
||||
const failedSession = await loadProjectSession(projectRoot);
|
||||
expect(failedSession.currentExecution?.status).toBe("failed");
|
||||
expect(failedSession.events.at(-1)?.kind).toBe("task.failed");
|
||||
expect(failedSession.events.at(-1)?.detail).toContain("boom");
|
||||
});
|
||||
|
||||
it("persists book selection into the shared project session", async () => {
|
||||
await persistProjectSession(projectRoot, createProjectSession(projectRoot));
|
||||
|
||||
const result = await processProjectInteractionInput({
|
||||
projectRoot,
|
||||
input: "/open harbor",
|
||||
tools: {
|
||||
writeNextChapter: vi.fn(async () => ({ ok: true })),
|
||||
reviseDraft: vi.fn(async () => ({ ok: true })),
|
||||
patchChapterText: vi.fn(async () => ({ ok: true })),
|
||||
renameEntity: vi.fn(async () => ({ ok: true })),
|
||||
updateCurrentFocus: vi.fn(async () => ({ ok: true })),
|
||||
updateAuthorIntent: vi.fn(async () => ({ ok: true })),
|
||||
writeTruthFile: vi.fn(async () => ({ ok: true })),
|
||||
listBooks: vi.fn(async () => ["harbor"]),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.session.activeBookId).toBe("harbor");
|
||||
expect(result.request.intent).toBe("select_book");
|
||||
});
|
||||
|
||||
it("persists structured create_book requests into the shared project session", async () => {
|
||||
await persistProjectSession(projectRoot, createProjectSession(projectRoot));
|
||||
|
||||
@@ -184,58 +70,4 @@ describe("project interaction control", () => {
|
||||
expect(persisted.activeBookId).toBe("night-harbor");
|
||||
});
|
||||
|
||||
it("persists a creation draft across explicit slash-command ideation turns", async () => {
|
||||
const ideationRoot = await mkdtemp(join(tmpdir(), "inkos-project-ideation-"));
|
||||
await writeFile(join(ideationRoot, "inkos.json"), JSON.stringify({ language: "zh" }), "utf-8");
|
||||
await persistProjectSession(ideationRoot, createProjectSession(ideationRoot));
|
||||
|
||||
const tools = {
|
||||
listBooks: vi.fn(async () => ["harbor"]),
|
||||
developBookDraft: vi.fn(async () => ({
|
||||
__interaction: {
|
||||
responseText: "我先按港风商战悬疑收着。你更想写长篇连载,还是十来章能收住?",
|
||||
details: {
|
||||
creationDraft: {
|
||||
concept: "港风商战悬疑,主角从灰产洗白。",
|
||||
title: "夜港账本",
|
||||
genre: "urban",
|
||||
nextQuestion: "更想写长篇连载,还是十来章能收住?",
|
||||
missingFields: ["targetChapters"],
|
||||
readyToCreate: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
createBook: vi.fn(async () => ({ ok: true })),
|
||||
exportBook: vi.fn(async () => ({ ok: true })),
|
||||
writeNextChapter: vi.fn(async () => ({ ok: true })),
|
||||
reviseDraft: vi.fn(async () => ({ ok: true })),
|
||||
patchChapterText: vi.fn(async () => ({ ok: true })),
|
||||
renameEntity: vi.fn(async () => ({ ok: true })),
|
||||
updateCurrentFocus: vi.fn(async () => ({ ok: true })),
|
||||
updateAuthorIntent: vi.fn(async () => ({ ok: true })),
|
||||
writeTruthFile: vi.fn(async () => ({ ok: true })),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await processProjectInteractionInput({
|
||||
projectRoot: ideationRoot,
|
||||
input: "/new 我想写个港风商战悬疑,主角从灰产洗白。",
|
||||
tools,
|
||||
});
|
||||
|
||||
expect(result.request.intent).toBe("develop_book");
|
||||
expect(result.session.creationDraft).toEqual(expect.objectContaining({
|
||||
title: "夜港账本",
|
||||
genre: "urban",
|
||||
}));
|
||||
|
||||
const persisted = await loadProjectSession(ideationRoot);
|
||||
expect(persisted.creationDraft).toEqual(expect.objectContaining({
|
||||
title: "夜港账本",
|
||||
}));
|
||||
} finally {
|
||||
await rm(ideationRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { AssistantMessage, Model, Api } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
__resetFixedTemperatureWarnings,
|
||||
chatCompletion,
|
||||
chatWithTools,
|
||||
type LLMClient,
|
||||
} from "../llm/provider.js";
|
||||
|
||||
@@ -285,33 +284,6 @@ describe("chatCompletion via pi-ai", () => {
|
||||
expect(mockCompleteSimple).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects oversized tool context before sending to pi-ai", async () => {
|
||||
const client = makeClient(0.7, {
|
||||
stream: false,
|
||||
_piModel: {
|
||||
...MOCK_PI_MODEL,
|
||||
contextWindow: 80,
|
||||
},
|
||||
});
|
||||
|
||||
const error = await captureError(
|
||||
chatWithTools(
|
||||
client,
|
||||
"test-model",
|
||||
[
|
||||
{ role: "system", content: "系统设定".repeat(40) },
|
||||
{ role: "user", content: "用户消息".repeat(40) },
|
||||
],
|
||||
[],
|
||||
{ maxTokens: 20 },
|
||||
),
|
||||
);
|
||||
|
||||
expect(error.message).toContain("context window");
|
||||
expect(error.message).toContain("compress");
|
||||
expect(mockComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onTextDelta for each text chunk", async () => {
|
||||
const msg = makeAssistantMessage("abc");
|
||||
mockStreamSimple.mockReturnValue(makeEventStream([
|
||||
|
||||
@@ -276,7 +276,6 @@ export {
|
||||
type NaturalLanguageRoutingContext,
|
||||
} from "./interaction/nl-router.js";
|
||||
export {
|
||||
processProjectInteractionInput,
|
||||
processProjectInteractionRequest,
|
||||
} from "./interaction/project-control.js";
|
||||
export { createInteractionToolsFromDeps } from "./interaction/project-tools.js";
|
||||
@@ -344,7 +343,7 @@ export {
|
||||
export * from "./agent/index.js";
|
||||
|
||||
// LLM
|
||||
export { createLLMClient, chatCompletion, chatWithTools, createStreamMonitor, PartialResponseError, type LLMClient, type LLMResponse, type LLMMessage, type ToolDefinition, type ToolCall, type AgentMessage, type ChatWithToolsResult, type StreamProgress, type OnStreamProgress } from "./llm/provider.js";
|
||||
export { createLLMClient, chatCompletion, createStreamMonitor, PartialResponseError, type LLMClient, type LLMResponse, type LLMMessage, type StreamProgress, type OnStreamProgress } from "./llm/provider.js";
|
||||
export {
|
||||
SERVICE_PRESETS,
|
||||
SERVICE_TO_PI_PROVIDER,
|
||||
@@ -430,7 +429,6 @@ export { analyzeHookHealth } from "./utils/hook-health.js";
|
||||
// Pipeline
|
||||
export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type PlanChapterResult, type ComposeChapterResult, type ReviseResult, type TruthFiles, type BookStatusInfo, type ImportChaptersInput, type ImportChaptersResult, type TokenUsageSummary } from "./pipeline/runner.js";
|
||||
export { Scheduler, type SchedulerConfig } from "./pipeline/scheduler.js";
|
||||
export { runAgentLoop, AGENT_TOOLS as AGENT_TOOLS, type AgentLoopOptions } from "./pipeline/agent.js";
|
||||
export { detectChapter, detectAndRewrite, loadDetectionHistory, type DetectChapterResult, type DetectAndRewriteResult } from "./pipeline/detection-runner.js";
|
||||
|
||||
// State
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { appendInteractionEvent, appendInteractionMessage } from "./session.js";
|
||||
import { routeNaturalLanguageIntent } from "./nl-router.js";
|
||||
import { appendInteractionEvent } from "./session.js";
|
||||
import type { InteractionRequest } from "./intents.js";
|
||||
import type { InteractionRuntimeTools } from "./runtime.js";
|
||||
import { runInteractionRequest } from "./runtime.js";
|
||||
@@ -60,62 +59,6 @@ async function processProjectInteractionRequestInternal(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function processProjectInteractionInput(params: {
|
||||
readonly projectRoot: string;
|
||||
readonly input: string;
|
||||
readonly tools: InteractionRuntimeTools;
|
||||
readonly activeBookId?: string;
|
||||
}) {
|
||||
const requestLanguage = await detectProjectInteractionLanguage(params.projectRoot);
|
||||
const session = await loadProjectSession(params.projectRoot);
|
||||
const restoredBookId = await resolveSessionActiveBook(params.projectRoot, session);
|
||||
const resolvedBookId = params.activeBookId ?? restoredBookId;
|
||||
const sessionWithBook = resolvedBookId && session.activeBookId !== resolvedBookId
|
||||
? { ...session, activeBookId: resolvedBookId }
|
||||
: session;
|
||||
const userSession = appendInteractionMessage(sessionWithBook, {
|
||||
role: "user",
|
||||
content: params.input,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const request = attachRequestLanguage(routeNaturalLanguageIntent(params.input, {
|
||||
activeBookId: userSession.activeBookId,
|
||||
hasCreationDraft: Boolean(userSession.creationDraft),
|
||||
}), requestLanguage);
|
||||
try {
|
||||
const result = await runInteractionRequest({
|
||||
session: userSession,
|
||||
request,
|
||||
tools: params.tools,
|
||||
});
|
||||
await persistProjectSession(params.projectRoot, result.session);
|
||||
return {
|
||||
...result,
|
||||
request,
|
||||
};
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
const failedSession = appendInteractionEvent({
|
||||
...userSession,
|
||||
currentExecution: {
|
||||
status: "failed",
|
||||
bookId: userSession.activeBookId,
|
||||
chapterNumber: userSession.activeChapterNumber,
|
||||
stageLabel: request.language === "en" ? `failed ${request.intent}` : `执行失败:${request.intent}`,
|
||||
},
|
||||
}, {
|
||||
kind: "task.failed",
|
||||
timestamp: Date.now(),
|
||||
status: "failed",
|
||||
bookId: userSession.activeBookId,
|
||||
chapterNumber: userSession.activeChapterNumber,
|
||||
detail,
|
||||
});
|
||||
await persistProjectSession(params.projectRoot, failedSession);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function processProjectInteractionRequest(params: {
|
||||
readonly projectRoot: string;
|
||||
readonly request: InteractionRequest;
|
||||
|
||||
@@ -8,13 +8,11 @@ import type {
|
||||
ReviseMode,
|
||||
LLMClient,
|
||||
BookConfig,
|
||||
ToolDefinition,
|
||||
} from "../index.js";
|
||||
import { chatCompletion, chatWithTools } from "../index.js";
|
||||
import { chatCompletion } from "../index.js";
|
||||
import { executeEditTransaction } from "./edit-controller.js";
|
||||
import { defaultChapterLength } from "../utils/length-metrics.js";
|
||||
import type { InteractionRuntimeTools } from "./runtime.js";
|
||||
import type { BookCreationDraft } from "./session.js";
|
||||
import { writeExportArtifact } from "./export-artifact.js";
|
||||
import { safeChildPath } from "../utils/path-safety.js";
|
||||
import { deriveBookIdFromTitle } from "../utils/book-id.js";
|
||||
@@ -329,209 +327,6 @@ async function withPipelineInteractionTelemetry<T extends { chapterNumber?: numb
|
||||
}
|
||||
}
|
||||
|
||||
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: "面向读者的故事简介。不要把所有设定混成唯一字段;能拆开的内容要分别写入下面字段。" },
|
||||
worldPremise: { type: "string", description: "世界观、故事发生环境、基本规则。" },
|
||||
settingNotes: { type: "string", description: "设定补充、时代质感、规则限制、不可变事实。" },
|
||||
protagonist: { type: "string", description: "主角身份、处境、欲望、压力、初始缺口。" },
|
||||
supportingCast: { type: "string", description: "关键配角及其利益关系。信息不足可留空。" },
|
||||
conflictCore: { type: "string", description: "核心冲突、主要压迫、读者期待的回报。" },
|
||||
volumeOutline: { type: "string", description: "第一卷或第一阶段方向,不要写成全书流水账。" },
|
||||
constraints: { type: "string", description: "用户明确提出的写作硬约束,如人称、比例、禁忌、节奏。" },
|
||||
authorIntent: { type: "string", description: "用户向前生效的创作意图和方向控制。" },
|
||||
currentFocus: { type: "string", description: "下一步最需要展开或确认的焦点。" },
|
||||
nextQuestion: { type: "string", description: "如果还缺关键信息,只问一个最重要的问题。" },
|
||||
missingFields: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "仍缺的故事核心字段 key,例如 worldPremise, protagonist, conflictCore。不要写 targetChapters/chapterWordCount,篇幅有默认值不算缺。",
|
||||
},
|
||||
readyToCreate: { type: "boolean", description: "只有故事核心信息齐全时才为 true。篇幅缺失不影响。" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const BOOK_DRAFT_SYSTEM_PROMPT = [
|
||||
"你是 InkOS 的建书草案助手。用户会分多轮描述想写的书,你需要调用 create_book 工具更新一份可编辑草案。",
|
||||
"",
|
||||
"规则:",
|
||||
"1. 不要把世界观、主角、冲突、卷纲全部塞进 brief;能拆开的内容必须分别写入 worldPremise、protagonist、conflictCore、volumeOutline 等字段。",
|
||||
"2. 按阶段收集:基础信息(title/genre/platform/language) -> 世界观(worldPremise/settingNotes) -> 角色(protagonist/supportingCast) -> 冲突(conflictCore/blurb/authorIntent) -> 结构(volumeOutline/currentFocus/constraints)。targetChapters/chapterWordCount 是运行参数,用户没说就别追问,系统会默认 200/3000。",
|
||||
"3. 用户只给一部分信息时,只更新这部分,不要为了 readyToCreate 编造剩余阶段。",
|
||||
"4. 信息还不够时,把 missingFields 写清楚,并在 nextQuestion 里只问一个最关键的问题。",
|
||||
"5. 只有 title、genre、platform、worldPremise、protagonist、conflictCore 都明确时,readyToCreate 才能为 true。篇幅不是必填项。",
|
||||
"6. 如果用户后续要求修改某些字段,重新调用 create_book 工具,只更新被提到的字段,其余保持不变。",
|
||||
"7. 不要只回复文字讨论——必须调用 create_book 工具输出结构化草案。",
|
||||
].join("\n");
|
||||
|
||||
/** Map directive field keys to BookCreationDraft property names. */
|
||||
function applyFieldsToDraft(
|
||||
existing: BookCreationDraft | undefined,
|
||||
fields: Readonly<Record<string, unknown>>,
|
||||
concept: string,
|
||||
): BookCreationDraft {
|
||||
const draft: BookCreationDraft = {
|
||||
concept,
|
||||
missingFields: [],
|
||||
readyToCreate: false,
|
||||
...(existing ?? {}),
|
||||
};
|
||||
|
||||
for (const [key, rawValue] of Object.entries(fields)) {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === "") continue;
|
||||
const value = typeof rawValue === "string" ? rawValue.trim() : rawValue;
|
||||
if (value === "") continue;
|
||||
|
||||
switch (key) {
|
||||
case "title":
|
||||
if (typeof value === "string") draft.title = value;
|
||||
break;
|
||||
case "genre":
|
||||
if (typeof value === "string") draft.genre = value;
|
||||
break;
|
||||
case "platform":
|
||||
if (typeof value === "string") draft.platform = value;
|
||||
break;
|
||||
case "language":
|
||||
if (value === "zh" || value === "en") draft.language = value;
|
||||
break;
|
||||
case "targetChapters": {
|
||||
const n = typeof value === "number" ? value : parseInt(String(value), 10);
|
||||
if (!Number.isNaN(n) && n > 0) draft.targetChapters = n;
|
||||
break;
|
||||
}
|
||||
case "chapterWordCount":
|
||||
case "chapterLength": {
|
||||
const n = typeof value === "number" ? value : parseInt(String(value), 10);
|
||||
if (!Number.isNaN(n) && n > 0) draft.chapterWordCount = n;
|
||||
break;
|
||||
}
|
||||
case "brief":
|
||||
case "blurb":
|
||||
if (typeof value === "string") draft.blurb = value;
|
||||
break;
|
||||
case "worldPremise":
|
||||
if (typeof value === "string") draft.worldPremise = value;
|
||||
break;
|
||||
case "settingNotes":
|
||||
if (typeof value === "string") draft.settingNotes = value;
|
||||
break;
|
||||
case "protagonist":
|
||||
if (typeof value === "string") draft.protagonist = value;
|
||||
break;
|
||||
case "supportingCast":
|
||||
if (typeof value === "string") draft.supportingCast = value;
|
||||
break;
|
||||
case "conflictCore":
|
||||
if (typeof value === "string") draft.conflictCore = value;
|
||||
break;
|
||||
case "volumeOutline":
|
||||
if (typeof value === "string") draft.volumeOutline = value;
|
||||
break;
|
||||
case "constraints":
|
||||
if (typeof value === "string") draft.constraints = value;
|
||||
break;
|
||||
case "authorIntent":
|
||||
if (typeof value === "string") draft.authorIntent = value;
|
||||
break;
|
||||
case "currentFocus":
|
||||
if (typeof value === "string") draft.currentFocus = value;
|
||||
break;
|
||||
case "nextQuestion":
|
||||
if (typeof value === "string") draft.nextQuestion = value;
|
||||
break;
|
||||
case "missingFields":
|
||||
if (Array.isArray(value)) {
|
||||
draft.missingFields = value
|
||||
.filter((field): field is string => typeof field === "string" && field.trim().length > 0)
|
||||
.map((field) => field.trim());
|
||||
}
|
||||
break;
|
||||
case "readyToCreate":
|
||||
if (typeof value === "boolean") draft.readyToCreate = value;
|
||||
break;
|
||||
// Unknown keys are silently ignored — the LLM may emit
|
||||
// application-level keys we don't map to the draft struct.
|
||||
}
|
||||
}
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
// Length is a run parameter, not a story-core field: the user shouldn't be
|
||||
// blocked on "how many chapters" the way they're blocked on "who's the
|
||||
// protagonist". We fill editable defaults instead of treating them as
|
||||
// must-ask fields. These mirror the BookSchema defaults in models/book.ts.
|
||||
const DEFAULT_DRAFT_TARGET_CHAPTERS = 200;
|
||||
const DEFAULT_DRAFT_CHAPTER_WORD_COUNT = 3000;
|
||||
|
||||
// The story-core fields the user MUST supply before a book can be created.
|
||||
// Length (targetChapters/chapterWordCount) is intentionally absent — it's
|
||||
// defaulted in finalizeBookDraft and shown editable in the draft summary.
|
||||
function missingCoreDraftFields(draft: BookCreationDraft): string[] {
|
||||
const missing: string[] = [];
|
||||
if (!draft.title?.trim()) missing.push("title");
|
||||
if (!draft.genre?.trim()) missing.push("genre");
|
||||
if (!draft.platform?.trim()) missing.push("platform");
|
||||
if (!draft.worldPremise?.trim()) missing.push("worldPremise");
|
||||
if (!draft.protagonist?.trim()) missing.push("protagonist");
|
||||
if (!draft.conflictCore?.trim()) missing.push("conflictCore");
|
||||
return missing;
|
||||
}
|
||||
|
||||
function finalizeBookDraft(draft: BookCreationDraft): BookCreationDraft {
|
||||
// Fill editable length defaults so the draft always carries a concrete,
|
||||
// user-visible run parameter rather than building from a hidden fallback.
|
||||
const withDefaults: BookCreationDraft = {
|
||||
...draft,
|
||||
targetChapters:
|
||||
typeof draft.targetChapters === "number" ? draft.targetChapters : DEFAULT_DRAFT_TARGET_CHAPTERS,
|
||||
chapterWordCount:
|
||||
typeof draft.chapterWordCount === "number" ? draft.chapterWordCount : DEFAULT_DRAFT_CHAPTER_WORD_COUNT,
|
||||
};
|
||||
const coreMissing = missingCoreDraftFields(withDefaults);
|
||||
const missingFields = Array.from(new Set([...coreMissing, ...(withDefaults.missingFields ?? [])]));
|
||||
return {
|
||||
...withDefaults,
|
||||
missingFields,
|
||||
readyToCreate: withDefaults.readyToCreate === true && coreMissing.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -549,71 +344,6 @@ export function createInteractionToolsFromDeps(
|
||||
|
||||
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",
|
||||
"platform",
|
||||
"worldPremise",
|
||||
"protagonist",
|
||||
"conflictCore",
|
||||
],
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const draft = finalizeBookDraft(applyFieldsToDraft(existingDraft, parsedArgs, concept));
|
||||
|
||||
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) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AutomationMode } from "./modes.js";
|
||||
import { routeInteractionRequest } from "./request-router.js";
|
||||
import type { InteractionRequest } from "./intents.js";
|
||||
import type { ExecutionState, InteractionEvent } from "./events.js";
|
||||
import type { PendingDecision, InteractionSession, DraftRound } from "./session.js";
|
||||
import type { PendingDecision, InteractionSession } from "./session.js";
|
||||
import {
|
||||
appendInteractionEvent,
|
||||
bindActiveBook,
|
||||
@@ -17,10 +17,6 @@ type RuntimeLanguage = "zh" | "en";
|
||||
|
||||
export interface InteractionRuntimeTools {
|
||||
readonly listBooks: () => Promise<ReadonlyArray<string>>;
|
||||
readonly developBookDraft?: (
|
||||
input: string,
|
||||
existingDraft?: InteractionSession["creationDraft"],
|
||||
) => Promise<unknown>;
|
||||
readonly createBook?: (input: {
|
||||
readonly title: string;
|
||||
readonly genre?: string;
|
||||
@@ -177,15 +173,6 @@ function buildTaskStartedState(
|
||||
en: "preparing chapter inputs",
|
||||
}),
|
||||
};
|
||||
case "develop_book":
|
||||
return {
|
||||
status: "planning",
|
||||
bookId: request.bookId ?? session.activeBookId,
|
||||
stageLabel: localize(language, {
|
||||
zh: "收敛创作草案",
|
||||
en: "developing book draft",
|
||||
}),
|
||||
};
|
||||
case "create_book":
|
||||
return {
|
||||
status: "planning",
|
||||
@@ -358,58 +345,6 @@ async function handleDraftLifecycleRequest(params: {
|
||||
const { language, addEvent, markCompleted } = helpers;
|
||||
|
||||
switch (request.intent) {
|
||||
case "develop_book": {
|
||||
if (!tools.developBookDraft) {
|
||||
throw new Error(localize(language, {
|
||||
zh: "创作草案会话暂未实现。",
|
||||
en: "Book-draft ideation is not implemented yet.",
|
||||
}));
|
||||
}
|
||||
if (!request.instruction) {
|
||||
throw new Error(localize(language, {
|
||||
zh: "创作草案需要一条用户输入。",
|
||||
en: "Book-draft ideation requires user input.",
|
||||
}));
|
||||
}
|
||||
const toolResult = await tools.developBookDraft(request.instruction, session.creationDraft);
|
||||
const metadata = extractToolMetadata(toolResult);
|
||||
const draft = metadata.details?.creationDraft as InteractionSession["creationDraft"] | undefined;
|
||||
if (!draft) {
|
||||
throw new Error(localize(language, {
|
||||
zh: "创作草案工具没有返回草案数据。",
|
||||
en: "Book-draft tool did not return draft data.",
|
||||
}));
|
||||
}
|
||||
const newRound: DraftRound = {
|
||||
roundId: (session.draftRounds?.length ?? 0) + 1,
|
||||
userMessage: request.instruction ?? "",
|
||||
assistantRaw: metadata.details?.draftRaw as string ?? "",
|
||||
fieldsUpdated: (metadata.details?.fieldsUpdated as string[]) ?? [],
|
||||
summary: metadata.details?.draftSummary as string ?? "",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const withDraft = updateCreationDraft(session, draft);
|
||||
const withRounds = {
|
||||
...withDraft,
|
||||
draftRounds: [...(withDraft.draftRounds ?? []), newRound],
|
||||
};
|
||||
const nextSession = appendToolEvents(withRounds, metadata.events);
|
||||
const completed = {
|
||||
...markCompleted(nextSession),
|
||||
currentExecution: metadata.currentExecution ?? markCompleted(nextSession).currentExecution,
|
||||
};
|
||||
return {
|
||||
session: addEvent(completed, "task.completed", "completed", localize(language, {
|
||||
zh: "已更新创作草案。",
|
||||
en: "Updated the book draft.",
|
||||
})),
|
||||
responseText: metadata.responseText ?? localize(language, {
|
||||
zh: "已更新创作草案。",
|
||||
en: "Updated the book draft.",
|
||||
}),
|
||||
details: metadata.details,
|
||||
};
|
||||
}
|
||||
case "show_book_draft": {
|
||||
if (!session.creationDraft) {
|
||||
return {
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import type { LLMConfig } from "../models/project.js";
|
||||
import {
|
||||
streamSimple as piStreamSimple,
|
||||
stream as piStream,
|
||||
completeSimple as piCompleteSimple,
|
||||
complete as piComplete,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import type {
|
||||
Api as PiApi,
|
||||
Model as PiModel,
|
||||
Context as PiContext,
|
||||
AssistantMessageEvent,
|
||||
Tool as PiTool,
|
||||
TextContent as PiTextContent,
|
||||
ToolCall as PiToolCall,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { resolveServicePreset } from "./service-presets.js";
|
||||
import { getEndpoint } from "./providers/index.js";
|
||||
@@ -145,31 +140,6 @@ export interface LLMClient {
|
||||
};
|
||||
}
|
||||
|
||||
// === Tool-calling Types ===
|
||||
|
||||
export interface ToolDefinition {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly arguments: string;
|
||||
}
|
||||
|
||||
export type AgentMessage =
|
||||
| { readonly role: "system"; readonly content: string }
|
||||
| { readonly role: "user"; readonly content: string }
|
||||
| { readonly role: "assistant"; readonly content: string | null; readonly toolCalls?: ReadonlyArray<ToolCall> }
|
||||
| { readonly role: "tool"; readonly toolCallId: string; readonly content: string };
|
||||
|
||||
export interface ChatWithToolsResult {
|
||||
readonly content: string;
|
||||
readonly toolCalls: ReadonlyArray<ToolCall>;
|
||||
}
|
||||
|
||||
// === Factory ===
|
||||
|
||||
export function createLLMClient(config: LLMConfig): LLMClient {
|
||||
@@ -387,31 +357,6 @@ function estimateLLMMessagesTokens(messages: ReadonlyArray<LLMMessage>): number
|
||||
return messages.reduce((total, message) => total + estimateTextTokens(message.content), 0);
|
||||
}
|
||||
|
||||
function estimateAgentMessagesTokens(messages: ReadonlyArray<AgentMessage>): number {
|
||||
let total = 0;
|
||||
for (const message of messages) {
|
||||
if (message.role === "assistant") {
|
||||
total += estimateTextTokens(message.content ?? "");
|
||||
for (const call of message.toolCalls ?? []) {
|
||||
total += estimateTextTokens(call.name);
|
||||
total += estimateTextTokens(call.arguments);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (message.role === "tool") {
|
||||
total += estimateTextTokens(message.toolCallId);
|
||||
total += estimateTextTokens(message.content);
|
||||
continue;
|
||||
}
|
||||
total += estimateTextTokens(message.content);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function estimateToolsTokens(tools: ReadonlyArray<ToolDefinition>): number {
|
||||
return estimateTextTokens(JSON.stringify(tools));
|
||||
}
|
||||
|
||||
type PiMessageContent = PiContext["messages"][number]["content"];
|
||||
|
||||
function estimatePiContentTokens(content: PiMessageContent): number {
|
||||
@@ -1235,40 +1180,6 @@ export async function chatCompletion(
|
||||
}
|
||||
}
|
||||
|
||||
// === Tool-calling Chat (used by agent loop) ===
|
||||
|
||||
export async function chatWithTools(
|
||||
client: LLMClient,
|
||||
model: string,
|
||||
messages: ReadonlyArray<AgentMessage>,
|
||||
tools: ReadonlyArray<ToolDefinition>,
|
||||
options?: {
|
||||
readonly temperature?: number;
|
||||
readonly maxTokens?: number;
|
||||
},
|
||||
): Promise<ChatWithToolsResult> {
|
||||
const errorCtx = { baseUrl: client._piModel?.baseUrl ?? "(unknown)", model, service: client.service };
|
||||
try {
|
||||
const resolved = {
|
||||
temperature: clampTemperatureForModel(
|
||||
client.service,
|
||||
model,
|
||||
options?.temperature ?? client.defaults.temperature,
|
||||
),
|
||||
maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
|
||||
};
|
||||
assertWithinContextWindow({
|
||||
piModel: resolvePiModel(client, model),
|
||||
model,
|
||||
estimatedInputTokens: estimateAgentMessagesTokens(messages) + estimateToolsTokens(tools),
|
||||
reservedOutputTokens: resolved.maxTokens,
|
||||
});
|
||||
return await chatWithToolsViaPiAi(client, model, messages, tools, resolved);
|
||||
} catch (error) {
|
||||
throw wrapLLMError(error, errorCtx);
|
||||
}
|
||||
}
|
||||
|
||||
// === pi-ai Unified Implementation ===
|
||||
|
||||
/**
|
||||
@@ -1308,66 +1219,6 @@ function toPiContext(messages: ReadonlyArray<LLMMessage>): PiContext {
|
||||
return { systemPrompt, messages: piMessages };
|
||||
}
|
||||
|
||||
/** Convert inkos AgentMessage[] to pi-ai Context (with tool calls/results). */
|
||||
function agentMessagesToPiContext(messages: ReadonlyArray<AgentMessage>): PiContext {
|
||||
const systemParts = messages.filter((m) => m.role === "system").map((m) => (m as { content: string }).content);
|
||||
const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
|
||||
const piMessages: PiContext["messages"] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "system") continue;
|
||||
if (msg.role === "user") {
|
||||
piMessages.push({ role: "user", content: msg.content, timestamp: Date.now() });
|
||||
continue;
|
||||
}
|
||||
if (msg.role === "assistant") {
|
||||
const content: (PiTextContent | PiToolCall)[] = [];
|
||||
if (msg.content) content.push({ type: "text", text: msg.content });
|
||||
if (msg.toolCalls) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
content.push({
|
||||
type: "toolCall",
|
||||
id: tc.id,
|
||||
name: tc.name,
|
||||
arguments: JSON.parse(tc.arguments),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (content.length === 0) content.push({ type: "text", text: "" });
|
||||
piMessages.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
api: "openai-completions" as PiApi,
|
||||
provider: "openai",
|
||||
model: "",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (msg.role === "tool") {
|
||||
piMessages.push({
|
||||
role: "toolResult",
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: "",
|
||||
content: [{ type: "text", text: msg.content }],
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { systemPrompt, messages: piMessages };
|
||||
}
|
||||
|
||||
/** Convert inkos ToolDefinition[] to pi-ai Tool[]. */
|
||||
function toPiTools(tools: ReadonlyArray<ToolDefinition>): PiTool[] {
|
||||
return tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters as PiTool["parameters"],
|
||||
}));
|
||||
}
|
||||
|
||||
async function chatCompletionViaPiAi(
|
||||
client: LLMClient,
|
||||
model: string,
|
||||
@@ -1463,62 +1314,3 @@ async function chatCompletionViaPiAi(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function chatWithToolsViaPiAi(
|
||||
client: LLMClient,
|
||||
model: string,
|
||||
messages: ReadonlyArray<AgentMessage>,
|
||||
tools: ReadonlyArray<ToolDefinition>,
|
||||
resolved: { readonly temperature: number; readonly maxTokens: number },
|
||||
): Promise<ChatWithToolsResult> {
|
||||
const piModel = resolvePiModel(client, model);
|
||||
const context = agentMessagesToPiContext(messages);
|
||||
context.tools = toPiTools(tools);
|
||||
const streamOpts = {
|
||||
temperature: resolved.temperature,
|
||||
maxTokens: resolved.maxTokens,
|
||||
apiKey: client._apiKey,
|
||||
headers: mergeUserAgent(piModel.headers),
|
||||
};
|
||||
|
||||
if (!client.stream) {
|
||||
const response = await piComplete(piModel, context, streamOpts);
|
||||
if (response.stopReason === "error" && response.errorMessage) {
|
||||
throw new Error(response.errorMessage);
|
||||
}
|
||||
const content = response.content
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("");
|
||||
const toolCalls = response.content
|
||||
.filter((block): block is PiToolCall => block.type === "toolCall")
|
||||
.map((block) => ({
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.arguments),
|
||||
}));
|
||||
return { content, toolCalls };
|
||||
}
|
||||
|
||||
const eventStream = piStream(piModel, context, streamOpts);
|
||||
let content = "";
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
for await (const event of eventStream) {
|
||||
if (event.type === "text_delta") {
|
||||
content += event.delta;
|
||||
}
|
||||
if (event.type === "toolcall_end") {
|
||||
toolCalls.push({
|
||||
id: event.toolCall.id,
|
||||
name: event.toolCall.name,
|
||||
arguments: JSON.stringify(event.toolCall.arguments),
|
||||
});
|
||||
}
|
||||
if (event.type === "error" && event.error.errorMessage) {
|
||||
throw new Error(event.error.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return { content, toolCalls };
|
||||
}
|
||||
|
||||
@@ -1,695 +0,0 @@
|
||||
import { chatWithTools, type AgentMessage, type ToolDefinition } from "../llm/provider.js";
|
||||
import { PipelineRunner, type PipelineConfig } from "./runner.js";
|
||||
import { normalizePlatformOrOther, type Genre } from "../models/book.js";
|
||||
import { DEFAULT_REVISE_MODE, type ReviseMode } from "../agents/reviser.js";
|
||||
import { deriveBookIdFromTitle } from "../utils/book-id.js";
|
||||
import { inferLanguage } from "../utils/language.js";
|
||||
import { defaultChapterLength } from "../utils/length-metrics.js";
|
||||
|
||||
/** Tool definitions for the agent loop. */
|
||||
const TOOLS: ReadonlyArray<ToolDefinition> = [
|
||||
{
|
||||
name: "write_draft",
|
||||
description: "写【下一章】草稿。只能续写最新章之后的下一章,不能指定章节号,不能补历史空章。生成正文、更新状态卡/账本/伏笔池、保存章节文件。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "书籍ID" },
|
||||
guidance: { type: "string", description: "本章创作指导(可选,自然语言)" },
|
||||
},
|
||||
required: ["bookId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "plan_chapter",
|
||||
description: "为下一章生成 chapter intent(章节目标、必须保留、冲突说明)。适合在正式写作前检查当前控制输入是否正确。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "书籍ID" },
|
||||
guidance: { type: "string", description: "本章额外指导(可选,自然语言)" },
|
||||
},
|
||||
required: ["bookId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "compose_chapter",
|
||||
description: "为下一章生成 context/rule-stack/trace 运行时产物。适合在写作前确认系统实际会带哪些上下文和优先级。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "书籍ID" },
|
||||
guidance: { type: "string", description: "本章额外指导(可选,自然语言)" },
|
||||
},
|
||||
required: ["bookId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "audit_chapter",
|
||||
description: "审计指定章节。检查连续性、OOC、数值、伏笔等问题。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "书籍ID" },
|
||||
chapterNumber: { type: "number", description: "章节号(不填则审计最新章)" },
|
||||
},
|
||||
required: ["bookId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "revise_chapter",
|
||||
description: "修订指定章节的文字质量。根据审计问题做局部修正,不改变剧情走向。默认 spot-fix(定点修复最小改动);也支持 polish(润色)、rewrite(改写)、rework(重写)、anti-detect。注意:不能用来补缺失章节、不能改章节号、不能替代 write_draft。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "书籍ID" },
|
||||
chapterNumber: { type: "number", description: "章节号(不填则修订最新章)" },
|
||||
mode: { type: "string", enum: ["polish", "rewrite", "rework", "spot-fix", "anti-detect"], description: `修订模式(默认${DEFAULT_REVISE_MODE})` },
|
||||
},
|
||||
required: ["bookId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scan_market",
|
||||
description: "扫描市场趋势。从平台排行榜获取实时数据并分析。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create_book",
|
||||
description: "创建一本新书。生成世界观、卷纲、文风指南等基础设定。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "书名" },
|
||||
genre: { type: "string", enum: ["xuanhuan", "xianxia", "urban", "horror", "other"], description: "题材" },
|
||||
platform: { type: "string", enum: ["tomato", "feilu", "qidian", "other"], description: "目标平台" },
|
||||
brief: { type: "string", description: "创作简述/需求(自然语言)" },
|
||||
},
|
||||
required: ["title", "genre", "platform"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_author_intent",
|
||||
description: "更新书级长期意图文档 author_intent.md。用于修改这本书长期想成为什么。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "书籍ID" },
|
||||
content: { type: "string", description: "author_intent.md 的完整新内容" },
|
||||
},
|
||||
required: ["bookId", "content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update_current_focus",
|
||||
description: "更新当前关注点文档 current_focus.md。用于把最近几章的注意力拉回某条主线或冲突。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "书籍ID" },
|
||||
content: { type: "string", description: "current_focus.md 的完整新内容" },
|
||||
},
|
||||
required: ["bookId", "content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_book_status",
|
||||
description: "获取书籍状态概览:章数、字数、最近章节审计情况。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "书籍ID" },
|
||||
},
|
||||
required: ["bookId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "read_truth_files",
|
||||
description: "读取书籍的长期记忆(状态卡、资源账本、伏笔池)+ 世界观和卷纲。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "书籍ID" },
|
||||
},
|
||||
required: ["bookId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "list_books",
|
||||
description: "列出所有书籍。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "write_full_pipeline",
|
||||
description: "完整管线:写草稿 → 审计 → 自动修订(如需要)。一键完成。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "书籍ID" },
|
||||
count: { type: "number", description: "连续写几章(默认1)" },
|
||||
},
|
||||
required: ["bookId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "web_fetch",
|
||||
description: "抓取指定URL的文本内容。用于读取搜索结果中的详细页面。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", description: "要抓取的URL" },
|
||||
maxChars: { type: "number", description: "最大返回字符数(默认8000)" },
|
||||
},
|
||||
required: ["url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "import_style",
|
||||
description: "从参考文本生成文风指南(统计 + LLM定性分析)。生成 style_profile.json 和 style_guide.md。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "目标书籍ID" },
|
||||
referenceText: { type: "string", description: "参考文本(至少2000字)" },
|
||||
},
|
||||
required: ["bookId", "referenceText"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "import_canon",
|
||||
description: "从正传导入正典参照,生成 parent_canon.md,启用番外写作和审计模式。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
targetBookId: { type: "string", description: "番外书籍ID" },
|
||||
parentBookId: { type: "string", description: "正传书籍ID" },
|
||||
},
|
||||
required: ["targetBookId", "parentBookId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "import_chapters",
|
||||
description: "【整书重导】导入已有章节。从完整文本中自动分割所有章节,逐章分析并重建全部真相文件。这是整书级操作,不是补某一章的工具。导入后可用 write_draft 续写。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "目标书籍ID" },
|
||||
text: { type: "string", description: "包含多章的完整文本" },
|
||||
splitPattern: { type: "string", description: "章节分割正则(可选,默认匹配'第X章')" },
|
||||
},
|
||||
required: ["bookId", "text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "write_truth_file",
|
||||
description: "【整文件覆盖】直接替换书的真相文件内容。用于扩展大纲、修改世界观、调整规则。注意:这是整文件覆盖写入,不是追加;不要用来改 current_state.md 的章节进度指针或 hack 章节号;不要用来补空章节。book_rules.md / story_bible.md 是 Phase 5 之后的兼容指针,不再作为写入目标——请改写 outline/story_frame.md 的 YAML frontmatter。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
bookId: { type: "string", description: "书籍ID" },
|
||||
fileName: { type: "string", description: "文件名(如 outline/story_frame.md、outline/volume_map.md、outline/节奏原则.md(可选,Phase 5 后节奏原则合并到 volume_map 尾段,仅 legacy / 人工写入时出现)、roles/主要角色/<name>.md、roles/次要角色/<name>.md、current_state.md、pending_hooks.md)" },
|
||||
content: { type: "string", description: "新的完整文件内容" },
|
||||
},
|
||||
required: ["bookId", "fileName", "content"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export interface AgentLoopOptions {
|
||||
readonly onToolCall?: (name: string, args: Record<string, unknown>) => void;
|
||||
readonly onToolResult?: (name: string, result: string) => void;
|
||||
readonly onMessage?: (content: string) => void;
|
||||
readonly maxTurns?: number;
|
||||
}
|
||||
|
||||
export async function runAgentLoop(
|
||||
config: PipelineConfig,
|
||||
instruction: string,
|
||||
options?: AgentLoopOptions,
|
||||
): Promise<string> {
|
||||
const pipeline = new PipelineRunner(config);
|
||||
const { StateManager } = await import("../state/manager.js");
|
||||
const state = new StateManager(config.projectRoot);
|
||||
|
||||
const messages: AgentMessage[] = [
|
||||
{
|
||||
role: "system",
|
||||
content: `你是 InkOS 小说写作 Agent。用户是小说作者,你帮他管理从建书到成稿的全过程。
|
||||
|
||||
## 工具
|
||||
|
||||
| 工具 | 作用 |
|
||||
|------|------|
|
||||
| list_books | 列出所有书 |
|
||||
| get_book_status | 查看书的章数、字数、审计状态 |
|
||||
| read_truth_files | 读取长期记忆(状态卡、资源账本、伏笔池)和设定(世界观、卷纲、本书规则) |
|
||||
| create_book | 建书,生成世界观、卷纲、本书规则(自动加载题材 genre profile) |
|
||||
| plan_chapter | 先生成 chapter intent,确认本章目标/冲突/优先级 |
|
||||
| compose_chapter | 再生成 runtime context/rule stack,确认实际输入 |
|
||||
| write_draft | 写【下一章】草稿(只能续写最新章之后,不能补历史章) |
|
||||
| audit_chapter | 审计章节(32维度,按题材条件启用,含AI痕迹+敏感词检测) |
|
||||
| revise_chapter | 修订章节文字质量(不能补空章/改章号,五种模式) |
|
||||
| update_author_intent | 更新书级长期意图 author_intent.md |
|
||||
| update_current_focus | 更新当前关注点 current_focus.md |
|
||||
| write_full_pipeline | 完整管线:写 → 审 → 改(如需要) |
|
||||
| scan_market | 扫描平台排行榜,分析市场趋势 |
|
||||
| web_fetch | 抓取指定URL的文本内容 |
|
||||
| import_style | 从参考文本生成文风指南(统计+LLM分析) |
|
||||
| import_canon | 从正传导入正典参照,启用番外模式 |
|
||||
| import_chapters | 【整书重导】导入全部已有章节并重建真相文件 |
|
||||
| write_truth_file | 【整文件覆盖】替换真相文件内容,不能用来改章节进度 |
|
||||
|
||||
## 长期记忆
|
||||
|
||||
每本书有两层控制面:
|
||||
- **author_intent.md** — 这本书长期想成为什么
|
||||
- **current_focus.md** — 最近 1-3 章要把注意力拉回哪里
|
||||
|
||||
以及七个长期记忆文件,是 Agent 写作和审计的事实依据:
|
||||
- **current_state.md** — 角色位置、关系、已知信息、当前冲突
|
||||
- **particle_ledger.md** — 物品/资源账本,每笔增减有据可查
|
||||
- **pending_hooks.md** — 已埋伏笔、推进状态、预期回收时机
|
||||
- **chapter_summaries.md** — 每章压缩摘要(人物、事件、伏笔、情绪)
|
||||
- **subplot_board.md** — 支线进度板
|
||||
- **emotional_arcs.md** — 角色情感弧线
|
||||
- **character_matrix.md** — 角色交互矩阵与信息边界
|
||||
|
||||
## 管线逻辑
|
||||
|
||||
- audit 返回 passed=true → 不需要 revise
|
||||
- audit 返回 passed=false 且有 critical → 调 revise,改完可以再 audit
|
||||
- write_full_pipeline 会自动走完 写→审→改,适合不需要中间干预的场景
|
||||
|
||||
## 规则
|
||||
|
||||
- 用户提供了题材/创意但没说要扫描市场 → 跳过 scan_market,直接 create_book
|
||||
- 用户说了书名/bookId → 直接操作,不需要先 list_books
|
||||
- 每完成一步,简要汇报进展
|
||||
- 当用户要求“先把注意力拉回某条线”时,优先 update_current_focus,然后 plan_chapter / compose_chapter,再决定是否 write_draft 或 write_full_pipeline
|
||||
- 仿写流程:用户提供参考文本 → import_style → 生成 style_guide.md,后续写作自动参照
|
||||
- 番外流程:先 create_book 建番外书 → import_canon 导入正传正典 → 然后正常 write_draft
|
||||
- 续写流程:用户提供已有章节 → import_chapters → 然后 write_draft 续写
|
||||
|
||||
## 禁止事项(严格遵守)
|
||||
|
||||
- 不要用 write_draft 补历史中间章节。write_draft 只能写【当前最新章之后的下一章】
|
||||
- 不要用 import_chapters 修补某一个空章。import_chapters 是整书级重导工具
|
||||
- 不要用 write_truth_file 修改 current_state.md 的章节进度来"骗"系统跳到某一章
|
||||
- 不要用 revise_chapter 补缺失章节或改章节号。revise 只做文字质量修订
|
||||
- 用户说"补第 N 章"或"第 N 章是空的"时,先用 get_book_status 和 read_truth_files 判断真实状态,再决定用哪个工具
|
||||
- 不要在没有确认书籍状态的情况下直接调用写作工具`,
|
||||
},
|
||||
{ role: "user", content: instruction },
|
||||
];
|
||||
|
||||
const maxTurns = options?.maxTurns ?? 20;
|
||||
let lastAssistantMessage = "";
|
||||
|
||||
for (let turn = 0; turn < maxTurns; turn++) {
|
||||
const result = await chatWithTools(config.client, config.model, messages, TOOLS);
|
||||
|
||||
// Push assistant message to history
|
||||
messages.push({
|
||||
role: "assistant" as const,
|
||||
content: result.content || null,
|
||||
...(result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}),
|
||||
});
|
||||
|
||||
if (result.content) {
|
||||
lastAssistantMessage = result.content;
|
||||
options?.onMessage?.(result.content);
|
||||
}
|
||||
|
||||
// If no tool calls, we're done
|
||||
if (result.toolCalls.length === 0) break;
|
||||
|
||||
// Execute tool calls
|
||||
for (const toolCall of result.toolCalls) {
|
||||
let toolResult: string;
|
||||
try {
|
||||
const args = JSON.parse(toolCall.arguments) as Record<string, unknown>;
|
||||
options?.onToolCall?.(toolCall.name, args);
|
||||
toolResult = await executeTool(pipeline, state, config, toolCall.name, args);
|
||||
} catch (e) {
|
||||
toolResult = JSON.stringify({ error: String(e) });
|
||||
}
|
||||
|
||||
options?.onToolResult?.(toolCall.name, toolResult);
|
||||
messages.push({ role: "tool" as const, toolCallId: toolCall.id, content: toolResult });
|
||||
}
|
||||
}
|
||||
|
||||
return lastAssistantMessage;
|
||||
}
|
||||
|
||||
export async function executeAgentTool(
|
||||
pipeline: PipelineRunner,
|
||||
state: import("../state/manager.js").StateManager,
|
||||
config: PipelineConfig,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
switch (name) {
|
||||
case "plan_chapter": {
|
||||
const result = await pipeline.planChapter(
|
||||
args.bookId as string,
|
||||
args.guidance as string | undefined,
|
||||
);
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
case "compose_chapter": {
|
||||
const result = await pipeline.composeChapter(
|
||||
args.bookId as string,
|
||||
args.guidance as string | undefined,
|
||||
);
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
case "write_draft": {
|
||||
const bookId = args.bookId as string;
|
||||
const writeGuardError = await getSequentialWriteGuardError(state, bookId, "write_draft");
|
||||
if (writeGuardError) {
|
||||
return JSON.stringify({ error: writeGuardError });
|
||||
}
|
||||
const result = await pipeline.writeDraft(
|
||||
bookId,
|
||||
args.guidance as string | undefined,
|
||||
);
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
case "audit_chapter": {
|
||||
const result = await pipeline.auditDraft(
|
||||
args.bookId as string,
|
||||
args.chapterNumber as number | undefined,
|
||||
);
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
case "revise_chapter": {
|
||||
// Guard: target chapter must exist and have content
|
||||
const bookId = args.bookId as string;
|
||||
const chapterNum = args.chapterNumber as number | undefined;
|
||||
if (chapterNum !== undefined) {
|
||||
const index = await state.loadChapterIndex(bookId);
|
||||
const chapter = index.find((ch) => ch.number === chapterNum);
|
||||
if (!chapter) {
|
||||
return JSON.stringify({ error: `第${chapterNum}章不存在。revise_chapter 只能修订已有章节,不能用来补写缺失章节。请用 get_book_status 确认。` });
|
||||
}
|
||||
if (chapter.wordCount === 0) {
|
||||
return JSON.stringify({ error: `第${chapterNum}章内容为空(0字)。revise_chapter 不能修订空章节。` });
|
||||
}
|
||||
}
|
||||
const result = await pipeline.reviseDraft(
|
||||
bookId,
|
||||
chapterNum,
|
||||
(args.mode as ReviseMode) ?? DEFAULT_REVISE_MODE,
|
||||
);
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
case "scan_market": {
|
||||
const result = await pipeline.runRadar();
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
case "create_book": {
|
||||
const now = new Date().toISOString();
|
||||
const title = args.title as string;
|
||||
const bookId = deriveBookIdFromTitle(title) || `book-${Date.now().toString(36)}`;
|
||||
const brief = args.brief as string | undefined;
|
||||
const language = inferLanguage(brief ?? title);
|
||||
|
||||
const book = {
|
||||
id: bookId,
|
||||
title,
|
||||
platform: normalizePlatformOrOther(args.platform ?? "tomato"),
|
||||
genre: ((args.genre as string) ?? "xuanhuan") as Genre,
|
||||
status: "outlining" as const,
|
||||
targetChapters: 200,
|
||||
chapterWordCount: defaultChapterLength(language),
|
||||
language,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (brief) {
|
||||
const contextPipeline = new PipelineRunner({ ...config, externalContext: brief });
|
||||
await contextPipeline.initBook(book);
|
||||
} else {
|
||||
await pipeline.initBook(book);
|
||||
}
|
||||
|
||||
return JSON.stringify({ bookId, title, status: "created" });
|
||||
}
|
||||
|
||||
case "get_book_status": {
|
||||
const result = await pipeline.getBookStatus(args.bookId as string);
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
case "update_author_intent": {
|
||||
await state.ensureControlDocuments(args.bookId as string);
|
||||
const { writeFile } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const storyDir = join(state.bookDir(args.bookId as string), "story");
|
||||
await writeFile(join(storyDir, "author_intent.md"), args.content as string, "utf-8");
|
||||
return JSON.stringify({ bookId: args.bookId, file: "story/author_intent.md", written: true });
|
||||
}
|
||||
|
||||
case "update_current_focus": {
|
||||
await state.ensureControlDocuments(args.bookId as string);
|
||||
const { writeFile } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const storyDir = join(state.bookDir(args.bookId as string), "story");
|
||||
await writeFile(join(storyDir, "current_focus.md"), args.content as string, "utf-8");
|
||||
return JSON.stringify({ bookId: args.bookId, file: "story/current_focus.md", written: true });
|
||||
}
|
||||
|
||||
case "read_truth_files": {
|
||||
const result = await pipeline.readTruthFiles(args.bookId as string);
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
case "list_books": {
|
||||
const bookIds = await state.listBooks();
|
||||
const books = await Promise.all(
|
||||
bookIds.map(async (id) => {
|
||||
try {
|
||||
return await pipeline.getBookStatus(id);
|
||||
} catch {
|
||||
return { bookId: id, error: "failed to load" };
|
||||
}
|
||||
}),
|
||||
);
|
||||
return JSON.stringify(books);
|
||||
}
|
||||
|
||||
case "write_full_pipeline": {
|
||||
const bookId = args.bookId as string;
|
||||
const writeGuardError = await getSequentialWriteGuardError(state, bookId, "write_full_pipeline");
|
||||
if (writeGuardError) {
|
||||
return JSON.stringify({ error: writeGuardError });
|
||||
}
|
||||
const count = (args.count as number) ?? 1;
|
||||
const results = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const result = await pipeline.writeNextChapter(bookId);
|
||||
results.push(result);
|
||||
}
|
||||
return JSON.stringify(results);
|
||||
}
|
||||
|
||||
case "web_fetch": {
|
||||
const { fetchUrl } = await import("../utils/web-search.js");
|
||||
const text = await fetchUrl(args.url as string, (args.maxChars as number) ?? 8000);
|
||||
return JSON.stringify({ url: args.url, content: text });
|
||||
}
|
||||
|
||||
case "import_style": {
|
||||
const guide = await pipeline.generateStyleGuide(
|
||||
args.bookId as string,
|
||||
args.referenceText as string,
|
||||
);
|
||||
return JSON.stringify({
|
||||
bookId: args.bookId,
|
||||
statsProfile: "story/style_profile.json",
|
||||
styleGuide: "story/style_guide.md",
|
||||
guidePreview: guide.slice(0, 500),
|
||||
});
|
||||
}
|
||||
|
||||
case "import_canon": {
|
||||
const canon = await pipeline.importCanon(
|
||||
args.targetBookId as string,
|
||||
args.parentBookId as string,
|
||||
);
|
||||
return JSON.stringify({
|
||||
targetBookId: args.targetBookId,
|
||||
parentBookId: args.parentBookId,
|
||||
output: "story/parent_canon.md",
|
||||
canonPreview: canon.slice(0, 500),
|
||||
});
|
||||
}
|
||||
|
||||
case "import_chapters": {
|
||||
const { splitChapters } = await import("../utils/chapter-splitter.js");
|
||||
const chapters = splitChapters(
|
||||
args.text as string,
|
||||
args.splitPattern as string | undefined,
|
||||
);
|
||||
if (chapters.length === 0) {
|
||||
return JSON.stringify({ error: "No chapters found. Check text format or provide a splitPattern." });
|
||||
}
|
||||
// Guard: import_chapters is a whole-book reimport, not a single-chapter patch
|
||||
if (chapters.length === 1) {
|
||||
return JSON.stringify({ error: "import_chapters 是整书重导工具,需要至少 2 个章节。如果只想补一章,请用 write_draft 续写或 revise_chapter 修订。" });
|
||||
}
|
||||
const result = await pipeline.importChapters({
|
||||
bookId: args.bookId as string,
|
||||
chapters: [...chapters],
|
||||
});
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
case "write_truth_file": {
|
||||
const bookId = args.bookId as string;
|
||||
const fileName = args.fileName as string;
|
||||
const content = args.content as string;
|
||||
|
||||
// Whitelist allowed truth files.
|
||||
//
|
||||
// Hotfix: story_bible.md and book_rules.md are back in the whitelist —
|
||||
// they are authoritative for pre-Phase-5 books. For new-layout books
|
||||
// (outline/story_frame.md exists) they're compat shims and writes are
|
||||
// blocked below.
|
||||
const LEGACY_SHIM_FILES = new Set(["story_bible.md", "book_rules.md"]);
|
||||
const ALLOWED_FLAT_FILES = [
|
||||
"story_bible.md", "book_rules.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",
|
||||
];
|
||||
// outline/节奏原则.md (zh) / outline/rhythm_principles.md (en) are
|
||||
// optional after Phase 5 consolidation — rhythm principles normally live
|
||||
// in the last paragraph of volume_map and writeFoundationFiles skips the
|
||||
// dedicated file when the block is empty. They remain whitelisted so
|
||||
// legacy books and manual overrides keep working.
|
||||
const ALLOWED_OUTLINE_FILES = [
|
||||
"outline/story_frame.md", "outline/volume_map.md",
|
||||
"outline/节奏原则.md", "outline/rhythm_principles.md",
|
||||
];
|
||||
// Phase hotfix 3: accept both locale dirs so English-layout books can
|
||||
// be edited via write_truth_file. The reader (utils/outline-paths.ts)
|
||||
// and Studio (server.ts) accept both — the agent whitelist must match.
|
||||
const ROLE_PATH_PATTERN = /^roles\/(主要角色|次要角色|major|minor)\/[^/]+\.md$/;
|
||||
|
||||
const isAllowed =
|
||||
ALLOWED_FLAT_FILES.includes(fileName)
|
||||
|| ALLOWED_OUTLINE_FILES.includes(fileName)
|
||||
|| ROLE_PATH_PATTERN.test(fileName);
|
||||
|
||||
if (!isAllowed) {
|
||||
const allowedExamples = [
|
||||
...ALLOWED_FLAT_FILES,
|
||||
...ALLOWED_OUTLINE_FILES,
|
||||
"roles/主要角色/<name>.md",
|
||||
"roles/次要角色/<name>.md",
|
||||
"roles/major/<name>.md",
|
||||
"roles/minor/<name>.md",
|
||||
];
|
||||
return JSON.stringify({
|
||||
error:
|
||||
`不允许修改文件 "${fileName}"。允许的文件:${allowedExamples.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// For new-layout books, story_bible.md / book_rules.md are shims —
|
||||
// block writes so the agent edits outline/story_frame.md instead.
|
||||
if (LEGACY_SHIM_FILES.has(fileName)) {
|
||||
const { isNewLayoutBook } = await import("../utils/outline-paths.js");
|
||||
const bookDirForCheck = new (await import("../state/manager.js")).StateManager(config.projectRoot).bookDir(bookId);
|
||||
if (await isNewLayoutBook(bookDirForCheck)) {
|
||||
return JSON.stringify({
|
||||
error: `"${fileName}" 是兼容指针(新布局书籍),请改写 outline/story_frame.md。`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Path traversal guard — the whitelist already forbids `..`, but we
|
||||
// re-assert at the write site so this cannot regress.
|
||||
if (fileName.includes("..") || fileName.startsWith("/") || fileName.includes("\0")) {
|
||||
return JSON.stringify({ error: `不安全的文件路径:"${fileName}"` });
|
||||
}
|
||||
|
||||
// Guard: block chapter progress manipulation via current_state.md
|
||||
if (fileName === "current_state.md" && containsProgressManipulation(content)) {
|
||||
return JSON.stringify({ error: "不允许通过 write_truth_file 修改 current_state.md 中的章节进度。章节进度由系统自动管理。" });
|
||||
}
|
||||
|
||||
const { writeFile, mkdir } = await import("node:fs/promises");
|
||||
const { join, dirname } = await import("node:path");
|
||||
const bookDir = new (await import("../state/manager.js")).StateManager(config.projectRoot).bookDir(bookId);
|
||||
const storyDir = join(bookDir, "story");
|
||||
const targetPath = join(storyDir, fileName);
|
||||
await mkdir(dirname(targetPath), { recursive: true });
|
||||
await writeFile(targetPath, content, "utf-8");
|
||||
|
||||
return JSON.stringify({
|
||||
bookId,
|
||||
file: `story/${fileName}`,
|
||||
written: true,
|
||||
size: content.length,
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return JSON.stringify({ error: `Unknown tool: ${name}` });
|
||||
}
|
||||
}
|
||||
|
||||
async function executeTool(
|
||||
pipeline: PipelineRunner,
|
||||
state: import("../state/manager.js").StateManager,
|
||||
config: PipelineConfig,
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
return executeAgentTool(pipeline, state, config, name, args);
|
||||
}
|
||||
|
||||
async function getSequentialWriteGuardError(
|
||||
state: import("../state/manager.js").StateManager,
|
||||
bookId: string,
|
||||
toolName: "write_draft" | "write_full_pipeline",
|
||||
): Promise<string | null> {
|
||||
const nextNum = await state.getNextChapterNumber(bookId);
|
||||
const index = await state.loadChapterIndex(bookId);
|
||||
if (index.length === 0) return null;
|
||||
const lastIndexedChapter = index[index.length - 1]!.number;
|
||||
if (lastIndexedChapter === nextNum - 1) return null;
|
||||
return `${toolName} 只能续写下一章(当前应写第${nextNum}章)。检测到章节索引与运行时进度不一致,请先用 get_book_status 确认状态。`;
|
||||
}
|
||||
|
||||
function containsProgressManipulation(content: string): boolean {
|
||||
const patterns = [
|
||||
/\blastAppliedChapter\b/i,
|
||||
/\|\s*Current Chapter\s*\|\s*\d+\s*\|/i,
|
||||
/\|\s*当前章(?:节)?\s*\|\s*\d+\s*\|/,
|
||||
/\bCurrent Chapter\b\s*[::]\s*\d+/i,
|
||||
/当前章(?:节)?\s*[::]\s*\d+/,
|
||||
/\bprogress\b\s*[::]\s*\d+/i,
|
||||
/进度\s*[::]\s*\d+/,
|
||||
];
|
||||
return patterns.some((pattern) => pattern.test(content));
|
||||
}
|
||||
|
||||
/** Export tool definitions so external systems can reference them. */
|
||||
export { TOOLS as AGENT_TOOLS };
|
||||
@@ -17,7 +17,6 @@ const createLLMClientMock = vi.fn(() => ({}));
|
||||
const chatCompletionMock = vi.fn();
|
||||
const loadProjectConfigMock = vi.fn();
|
||||
const pipelineConfigs: unknown[] = [];
|
||||
const processProjectInteractionInputMock = vi.fn();
|
||||
const processProjectInteractionRequestMock = vi.fn();
|
||||
const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
|
||||
const loadProjectSessionMock = vi.fn();
|
||||
@@ -221,7 +220,6 @@ vi.mock("@actalk/inkos-core", async (importOriginal) => {
|
||||
inferLanguage: actual.inferLanguage,
|
||||
chatCompletion: chatCompletionMock,
|
||||
loadProjectConfig: loadProjectConfigMock,
|
||||
processProjectInteractionInput: processProjectInteractionInputMock,
|
||||
processProjectInteractionRequest: processProjectInteractionRequestMock,
|
||||
createInteractionToolsFromDeps: createInteractionToolsFromDepsMock,
|
||||
loadProjectSession: loadProjectSessionMock,
|
||||
@@ -362,7 +360,6 @@ describe("createStudioServer daemon lifecycle", () => {
|
||||
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
|
||||
});
|
||||
loadProjectConfigMock.mockReset();
|
||||
processProjectInteractionInputMock.mockReset();
|
||||
processProjectInteractionRequestMock.mockReset();
|
||||
createInteractionToolsFromDepsMock.mockReset();
|
||||
loadProjectSessionMock.mockReset();
|
||||
|
||||
Reference in New Issue
Block a user