feat(core): split backend changes from #182

This commit is contained in:
Ma
2026-04-15 11:15:53 +08:00
parent b6c631684f
commit 8dd296c24b
58 changed files with 4176 additions and 1126 deletions
@@ -13,6 +13,7 @@ function createSession(): InteractionSession {
automationMode: "semi",
messages: [],
events: [],
draftRounds: [],
};
}
@@ -36,6 +36,7 @@ function createSession(): InteractionSession {
detail: "Preparing chapter 12.",
},
],
draftRounds: [],
};
}
+3
View File
@@ -140,6 +140,9 @@ export async function launchTui(
onChatTextDelta: (text) => {
chatStreamBridge.onTextDelta?.(text);
},
onDraftTextDelta: (text) => {
chatStreamBridge.onTextDelta?.(text);
},
}));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
+11 -6
View File
@@ -105,13 +105,9 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
)}
</Box>
{/* Status strip */}
{/* Composer area */}
<Box flexDirection="column" marginTop={1}>
<Text color={WARM_BORDER}>{thinRule}</Text>
<Box marginTop={1}>
<ExecutionBadge status={model.executionStatus} color={activeAccent} />
<Text color={activeAccent}> {model.statusPrimaryLine}</Text>
</Box>
{/* Composer input */}
<Box
@@ -159,6 +155,10 @@ export function InkTuiDashboard(props: InkTuiDashboardProps): React.JSX.Element
</Box>
) : null}
</Box>
<Box marginTop={1}>
<ExecutionBadge status={model.executionStatus} color={activeAccent} />
<Text color={activeAccent}> {model.statusPrimaryLine}</Text>
</Box>
</Box>
</Box>
);
@@ -358,7 +358,8 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
hasFailed: session.currentExecution?.status === "failed",
});
const userTimestamp = Date.now();
const assistantDraftTimestamp = routed.intent === "chat" ? userTimestamp + 1 : null;
const assistantDraftTimestamp = (routed.intent === "chat" || routed.intent === "develop_book")
? userTimestamp + 1 : null;
assistantDraftTimestampRef.current = assistantDraftTimestamp;
setActivityIntent(routed.intent);
setIsSubmitting(true);
@@ -368,6 +369,10 @@ export function InkTuiApp(props: InkTuiAppProps): React.JSX.Element {
setHistoryState({ cursor: null, draft: "" });
setSession((current) => createOptimisticUserMessageSession(current, input, userTimestamp));
if (routed.intent === "develop_book" && !session.creationDraft) {
appendSystemNote(copy.notes.newBookGuide);
}
const result = await processProjectInteractionInput({
projectRoot: props.projectRoot,
input,
+7 -4
View File
@@ -36,6 +36,7 @@ export interface TuiCopy {
readonly status: (stage: string, mode: string) => string;
readonly config: string;
readonly depthSet: (depthLabel: string) => string;
readonly newBookGuide: string;
readonly noLlmConfig: string;
readonly setupProvider: string;
readonly toolInitFailed: (message: string) => string;
@@ -93,16 +94,17 @@ const ZH_CN: TuiCopy = {
composer: {
placeholder: "告诉 InkOS 要写什么、修改什么,或解释什么…",
emptyConversation: "先告诉 InkOS 你要做什么。",
helper: "回车发送 • /new • /draft • /create • /write • /books • /open • /mode • /depth • /help",
helper: "回车发送 • /new 输入你的想法,自动构建新书 • /draft • /create • /write • /books • /open • /mode • /depth • /help",
submitting: "处理中…",
failed: "上次请求失败",
ready: "就绪",
},
notes: {
help: "可用命令:/new、/draft、/create、/discard、/write、/books、/open、/mode、/rewrite、/focus、/truth、/rename、/replace、/export、/status、/clear、/depth、/quit。也支持直接输入自然语言。",
help: "可用命令:/new(输入想法,自动构建新书)、/draft、/create、/discard、/write、/books、/open、/mode、/rewrite、/focus、/truth、/rename、/replace、/export、/status、/clear、/depth、/quit。也支持直接输入自然语言。",
status: (stage, mode) => `当前状态:${stage}${mode})。`,
config: "当前 Ink 仪表盘里还不支持交互式 /config。请使用 inkos config set-global。",
depthSet: (depthLabel) => `思考深度已切换为 ${depthLabel}`,
newBookGuide: "开始构思新书。直接描述你的想法——题材、世界观、主角、核心冲突都可以。AI 会逐步引导你完善草案,随时用 /draft 查看进度,/create 建书。",
noLlmConfig: "未发现 LLM 配置。",
setupProvider: "先配置 API 提供方。",
toolInitFailed: (message) => `初始化 TUI 工具失败:${message}`,
@@ -181,16 +183,17 @@ const EN: TuiCopy = {
composer: {
placeholder: "Ask InkOS to write, revise, or explain…",
emptyConversation: "Start by asking InkOS what to do.",
helper: "Enter to send • /new • /draft • /create • /write • /books • /open • /mode • /depth • /help",
helper: "Enter to send • /new describe your idea to start a book • /draft • /create • /write • /books • /open • /mode • /depth • /help",
submitting: "Submitting…",
failed: "Last request failed",
ready: "Ready",
},
notes: {
help: "Commands: /new, /draft, /create, /discard, /write, /books, /open, /mode, /rewrite, /focus, /truth, /rename, /replace, /export, /status, /clear, /depth, /quit. Natural language still works.",
help: "Commands: /new (describe your idea to start a book), /draft, /create, /discard, /write, /books, /open, /mode, /rewrite, /focus, /truth, /rename, /replace, /export, /status, /clear, /depth, /quit. Natural language still works.",
status: (stage, mode) => `Status: ${stage} (${mode}).`,
config: "Interactive /config is not available inside the Ink dashboard yet. Use inkos config set-global.",
depthSet: (depthLabel) => `Thinking depth set to ${depthLabel}.`,
newBookGuide: "Starting a new book. Describe your idea — genre, world, protagonist, core conflict, anything. The AI will guide you step by step. Use /draft to check progress, /create to finalize.",
noLlmConfig: "No LLM configuration found.",
setupProvider: "Let's set up your API provider first.",
toolInitFailed: (message) => `Failed to initialize TUI tools: ${message}`,
+1 -1
View File
@@ -1,5 +1,5 @@
export const SLASH_COMMANDS = [
"/new <idea>",
"/new 输入你的想法",
"/draft",
"/create",
"/discard",
+1
View File
@@ -10,6 +10,7 @@ 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;
+3 -2
View File
@@ -44,10 +44,11 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@mariozechner/pi-agent-core": "^0.67.1",
"@mariozechner/pi-ai": "^0.67.1",
"@sinclair/typebox": "^0.34.49",
"dotenv": "^16.4.0",
"js-yaml": "^4.1.1",
"openai": "^4.80.0",
"zod": "^3.24.0"
},
"devDependencies": {
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { buildAgentSystemPrompt } from "../agent/agent-system-prompt.js";
describe("buildAgentSystemPrompt", () => {
describe("no book (creation flow)", () => {
it("Chinese prompt includes info collection workflow", () => {
const prompt = buildAgentSystemPrompt(null, "zh");
expect(prompt).toContain("建书助手");
expect(prompt).toContain("收集信息");
expect(prompt).toContain("题材");
expect(prompt).toContain("世界观");
expect(prompt).toContain("主角");
expect(prompt).toContain("核心冲突");
expect(prompt).toContain("architect");
expect(prompt).toContain("sub_agent");
});
it("English prompt includes info collection workflow", () => {
const prompt = buildAgentSystemPrompt(null, "en");
expect(prompt).toContain("book creation");
expect(prompt).toContain("architect");
expect(prompt).toContain("Genre");
expect(prompt).toContain("Protagonist");
expect(prompt).toContain("Core conflict");
});
it("Chinese prompt forbids emoji", () => {
const prompt = buildAgentSystemPrompt(null, "zh");
expect(prompt).toContain("不要在回复中添加表情符号");
});
it("English prompt forbids emoji", () => {
const prompt = buildAgentSystemPrompt(null, "en");
expect(prompt).toContain("Do NOT use emoji");
});
it("no-book prompt does NOT mention read/edit/grep/ls", () => {
const prompt = buildAgentSystemPrompt(null, "zh");
expect(prompt).not.toMatch(/\bread\b.*读取/);
expect(prompt).not.toContain("edit");
});
});
describe("with book (writing flow)", () => {
it("Chinese prompt includes all tools except architect", () => {
const prompt = buildAgentSystemPrompt("my-book", "zh");
expect(prompt).toContain("my-book");
expect(prompt).toContain("sub_agent");
expect(prompt).toContain("writer");
expect(prompt).toContain("auditor");
expect(prompt).toContain("reviser");
expect(prompt).toContain("read");
expect(prompt).toContain("edit");
expect(prompt).toContain("grep");
expect(prompt).toContain("ls");
});
it("Chinese prompt warns NOT to call architect", () => {
const prompt = buildAgentSystemPrompt("my-book", "zh");
expect(prompt).toContain("不要调用 architect");
});
it("English prompt warns NOT to call architect", () => {
const prompt = buildAgentSystemPrompt("novel", "en");
expect(prompt).toContain("Do NOT call architect");
});
it("Chinese with-book prompt forbids emoji", () => {
const prompt = buildAgentSystemPrompt("my-book", "zh");
expect(prompt).toContain("不要在回复中添加表情符号");
});
it("English with-book prompt forbids emoji", () => {
const prompt = buildAgentSystemPrompt("novel", "en");
expect(prompt).toContain("Do NOT use emoji");
});
it("with-book prompt does NOT list architect as available", () => {
const prompt = buildAgentSystemPrompt("my-book", "zh");
// architect 不在可用工具列表里
expect(prompt).not.toMatch(/agent="architect"/);
});
});
});
@@ -0,0 +1,109 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
loadBookSession,
persistBookSession,
listBookSessions,
findOrCreateBookSession,
} from "../interaction/book-session-store.js";
import { createBookSession, appendBookSessionMessage } from "../interaction/session.js";
describe("book-session-store", () => {
let tempDir: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "inkos-test-"));
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});
describe("persistBookSession + loadBookSession", () => {
it("round-trips a session", async () => {
const session = createBookSession("my-book");
await persistBookSession(tempDir, session);
const loaded = await loadBookSession(tempDir, session.sessionId);
expect(loaded).not.toBeNull();
expect(loaded!.sessionId).toBe(session.sessionId);
expect(loaded!.bookId).toBe("my-book");
});
it("returns null for non-existent session", async () => {
const loaded = await loadBookSession(tempDir, "nonexistent");
expect(loaded).toBeNull();
});
it("persists messages", async () => {
let session = createBookSession("book");
session = appendBookSessionMessage(session, { role: "user" as const, content: "test", timestamp: 100 });
await persistBookSession(tempDir, session);
const loaded = await loadBookSession(tempDir, session.sessionId);
expect(loaded!.messages).toHaveLength(1);
expect(loaded!.messages[0].content).toBe("test");
});
});
describe("listBookSessions", () => {
it("returns empty for no sessions", async () => {
const list = await listBookSessions(tempDir, "no-book");
expect(list).toEqual([]);
});
it("filters by bookId", async () => {
const s1 = createBookSession("book-a");
const s2 = createBookSession("book-b");
const s3 = createBookSession("book-a");
await persistBookSession(tempDir, s1);
await persistBookSession(tempDir, s2);
await persistBookSession(tempDir, s3);
const listA = await listBookSessions(tempDir, "book-a");
expect(listA).toHaveLength(2);
expect(listA.every((s) => s.bookId === "book-a")).toBe(true);
const listB = await listBookSessions(tempDir, "book-b");
expect(listB).toHaveLength(1);
});
it("sorts by updatedAt descending", async () => {
const s1 = { ...createBookSession("book"), updatedAt: 100 };
const s2 = { ...createBookSession("book"), updatedAt: 300 };
const s3 = { ...createBookSession("book"), updatedAt: 200 };
await persistBookSession(tempDir, s1);
await persistBookSession(tempDir, s2);
await persistBookSession(tempDir, s3);
const list = await listBookSessions(tempDir, "book");
expect(list[0].updatedAt).toBe(300);
expect(list[1].updatedAt).toBe(200);
expect(list[2].updatedAt).toBe(100);
});
it("lists null bookId sessions", async () => {
const s = createBookSession(null);
await persistBookSession(tempDir, s);
const list = await listBookSessions(tempDir, null);
expect(list).toHaveLength(1);
});
});
describe("findOrCreateBookSession", () => {
it("creates new if none exist", async () => {
const session = await findOrCreateBookSession(tempDir, "new-book");
expect(session.bookId).toBe("new-book");
// Verify it was persisted
const loaded = await loadBookSession(tempDir, session.sessionId);
expect(loaded).not.toBeNull();
});
it("returns existing if found", async () => {
const existing = createBookSession("book");
await persistBookSession(tempDir, existing);
const found = await findOrCreateBookSession(tempDir, "book");
expect(found.sessionId).toBe(existing.sessionId);
});
});
});
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
BookSessionSchema,
GlobalSessionSchema,
createBookSession,
appendBookSessionMessage,
} from "../interaction/session.js";
describe("BookSession", () => {
describe("BookSessionSchema", () => {
it("parses a valid session", () => {
const raw = {
sessionId: "123-abc",
bookId: "my-book",
messages: [],
draftRounds: [],
events: [],
createdAt: 1000,
updatedAt: 1000,
};
const result = BookSessionSchema.parse(raw);
expect(result.sessionId).toBe("123-abc");
expect(result.bookId).toBe("my-book");
});
it("accepts null bookId for draft sessions", () => {
const raw = {
sessionId: "123-abc",
bookId: null,
messages: [],
draftRounds: [],
events: [],
createdAt: 1000,
updatedAt: 1000,
};
const result = BookSessionSchema.parse(raw);
expect(result.bookId).toBeNull();
});
it("defaults empty arrays", () => {
const raw = {
sessionId: "123-abc",
bookId: null,
createdAt: 1000,
updatedAt: 1000,
};
const result = BookSessionSchema.parse(raw);
expect(result.messages).toEqual([]);
expect(result.draftRounds).toEqual([]);
expect(result.events).toEqual([]);
});
});
describe("GlobalSessionSchema", () => {
it("parses with defaults", () => {
const result = GlobalSessionSchema.parse({});
expect(result.automationMode).toBe("semi");
expect(result.activeBookId).toBeUndefined();
});
it("parses with values", () => {
const result = GlobalSessionSchema.parse({ activeBookId: "book-1", automationMode: "auto" });
expect(result.activeBookId).toBe("book-1");
expect(result.automationMode).toBe("auto");
});
});
describe("createBookSession", () => {
it("creates session with bookId", () => {
const session = createBookSession("my-book");
expect(session.bookId).toBe("my-book");
expect(session.sessionId).toBeTruthy();
expect(session.messages).toEqual([]);
expect(session.createdAt).toBeGreaterThan(0);
expect(session.updatedAt).toBe(session.createdAt);
});
it("creates session with null bookId", () => {
const session = createBookSession(null);
expect(session.bookId).toBeNull();
});
it("generates unique sessionIds", () => {
const a = createBookSession("book");
const b = createBookSession("book");
expect(a.sessionId).not.toBe(b.sessionId);
});
});
describe("appendBookSessionMessage", () => {
it("appends message and updates timestamp", () => {
const session = createBookSession("book");
const msg = { role: "user" as const, content: "hello", timestamp: Date.now() };
const updated = appendBookSessionMessage(session, msg);
expect(updated.messages).toHaveLength(1);
expect(updated.messages[0].content).toBe("hello");
expect(updated.updatedAt).toBeGreaterThanOrEqual(session.updatedAt);
});
it("sorts messages by timestamp", () => {
let session = createBookSession("book");
session = appendBookSessionMessage(session, { role: "user" as const, content: "second", timestamp: 200 });
session = appendBookSessionMessage(session, { role: "assistant" as const, content: "first", timestamp: 100 });
expect(session.messages[0].content).toBe("first");
expect(session.messages[1].content).toBe("second");
});
});
});
@@ -149,4 +149,50 @@ describe("persistChapterArtifacts", () => {
expect(snapshotState).not.toHaveBeenCalled();
expect(syncCurrentStateFactHistory).not.toHaveBeenCalled();
});
it("replaces existing entry for the same chapter number instead of appending", async () => {
const saveChapterIndex = vi.fn().mockResolvedValue(undefined);
const existingEntry: ChapterMeta = {
number: 1,
title: "Old Title",
status: "drafted",
wordCount: 500,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
auditIssues: [],
lengthWarnings: [],
};
await persistChapterArtifacts({
chapterNumber: 1,
chapterTitle: "New Title",
status: "ready-for-review",
auditResult: createAuditResult(),
finalWordCount: 2000,
lengthWarnings: [],
degradedIssues: [],
tokenUsage: ZERO_USAGE,
loadChapterIndex: async () => [existingEntry],
saveChapter: vi.fn().mockResolvedValue(undefined),
saveTruthFiles: vi.fn().mockResolvedValue(undefined),
saveChapterIndex,
markBookActiveIfNeeded: vi.fn().mockResolvedValue(undefined),
persistAuditDriftGuidance: vi.fn().mockResolvedValue(undefined),
snapshotState: vi.fn().mockResolvedValue(undefined),
syncCurrentStateFactHistory: vi.fn().mockResolvedValue(undefined),
logSnapshotStage: vi.fn(),
now: () => "2026-04-01T00:00:00.000Z",
});
const savedIndex = saveChapterIndex.mock.calls[0][0] as ChapterMeta[];
// Must have exactly 1 entry, not 2
expect(savedIndex).toHaveLength(1);
expect(savedIndex[0].number).toBe(1);
expect(savedIndex[0].title).toBe("New Title");
expect(savedIndex[0].wordCount).toBe(2000);
expect(savedIndex[0].status).toBe("ready-for-review");
// Must preserve original createdAt
expect(savedIndex[0].createdAt).toBe("2026-01-01T00:00:00.000Z");
expect(savedIndex[0].updatedAt).toBe("2026-04-01T00:00:00.000Z");
});
});
@@ -125,6 +125,54 @@ describe("validateChapterTruthPersistence", () => {
expect(logger.warn).toHaveBeenCalledWith(" [unsupported_change] 正文写铜牌在怀里,但 state 说未携带。");
});
it("degrades gracefully when validator throws (e.g. LLM returned empty response)", async () => {
const validator = {
validate: vi.fn().mockRejectedValue(new Error("LLM returned empty response")),
};
const writer = {
settleChapterState: vi.fn(),
};
const logWarn = vi.fn();
const logger = { warn: vi.fn() };
const result = await validateChapterTruthPersistence({
writer,
validator,
book: BOOK,
bookDir: "/tmp/book",
chapterNumber: 1,
title: "Test Chapter",
content: "Chapter content.",
persistenceOutput: createWriterOutput({
updatedState: "new state",
updatedHooks: "new hooks",
updatedLedger: "new ledger",
}),
auditResult: createAuditResult(),
previousTruth: {
oldState: "old state",
oldHooks: "old hooks",
oldLedger: "old ledger",
},
language: "zh",
logWarn,
logger,
});
expect(result.chapterStatus).toBe("state-degraded");
expect(result.persistenceOutput.updatedState).toBe("old state");
expect(result.persistenceOutput.updatedHooks).toBe("old hooks");
expect(result.persistenceOutput.updatedLedger).toBe("old ledger");
expect(result.degradedIssues).toEqual([
expect.objectContaining({
severity: "warning",
category: "state-validation",
}),
]);
// Should NOT have attempted settlement retry
expect(writer.settleChapterState).not.toHaveBeenCalled();
});
it("degrades persistence output and appends audit issues when retry still fails", async () => {
const validator = {
validate: vi.fn()
@@ -0,0 +1,102 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { migrateConfig } from "../llm/config-migration.js";
import { loadSecrets } from "../llm/secrets.js";
import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("config migration", () => {
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "inkos-migrate-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
it("migrates old llm.provider+model+apiKey to services[] + secrets", async () => {
const oldConfig = {
name: "mybook",
llm: {
provider: "openai",
model: "kimi-k2.5",
baseUrl: "https://api.moonshot.cn/v1",
apiKey: "sk-old-key",
},
language: "zh",
};
await writeFile(join(root, "inkos.json"), JSON.stringify(oldConfig));
const result = await migrateConfig(root);
expect(result.migrated).toBe(true);
const raw = await readFile(join(root, "inkos.json"), "utf-8");
const config = JSON.parse(raw);
expect(config.llm.services).toHaveLength(1);
expect(config.llm.services[0].service).toBe("moonshot");
expect(config.llm.services[0].apiKey).toBeUndefined();
expect(config.llm.defaultModel).toBe("kimi-k2.5");
expect(config.llm.provider).toBeUndefined();
expect(config.llm.model).toBeUndefined();
expect(config.llm.apiKey).toBeUndefined();
const secrets = await loadSecrets(root);
expect(secrets.services.moonshot.apiKey).toBe("sk-old-key");
});
it("does nothing if already in new format", async () => {
const newConfig = {
name: "mybook",
llm: {
services: [{ service: "moonshot" }],
defaultModel: "kimi-k2.5",
},
language: "zh",
};
await writeFile(join(root, "inkos.json"), JSON.stringify(newConfig));
const result = await migrateConfig(root);
expect(result.migrated).toBe(false);
});
it("guesses service from baseUrl", async () => {
const oldConfig = {
llm: {
provider: "openai",
model: "deepseek-chat",
baseUrl: "https://api.deepseek.com/v1",
apiKey: "sk-deep",
},
};
await writeFile(join(root, "inkos.json"), JSON.stringify(oldConfig));
await migrateConfig(root);
const raw = await readFile(join(root, "inkos.json"), "utf-8");
const config = JSON.parse(raw);
expect(config.llm.services[0].service).toBe("deepseek");
});
it("creates custom service when baseUrl is unrecognized", async () => {
const oldConfig = {
llm: {
provider: "openai",
model: "my-model",
baseUrl: "https://llm.internal.corp/v1",
apiKey: "sk-corp",
},
};
await writeFile(join(root, "inkos.json"), JSON.stringify(oldConfig));
await migrateConfig(root);
const raw = await readFile(join(root, "inkos.json"), "utf-8");
const config = JSON.parse(raw);
expect(config.llm.services[0].service).toBe("custom");
expect(config.llm.services[0].baseUrl).toBe("https://llm.internal.corp/v1");
expect(config.llm.services[0].name).toBe("Custom");
});
});
@@ -0,0 +1,386 @@
import { describe, it, expect } from "vitest";
import {
parseDraftDirectives,
createDirectiveStreamFilter,
} from "../interaction/draft-directive-parser.js";
// ---------------------------------------------------------------------------
// 1. Pure markdown — no directives
// ---------------------------------------------------------------------------
describe("parseDraftDirectives", () => {
it("returns empty fields and full text when input has no directives", () => {
const raw = "# 欢迎\n\n这是一段普通的 markdown,没有任何表单标记。";
const result = parseDraftDirectives(raw);
expect(result.fields).toEqual({});
expect(result.textContent).toBe(raw);
expect(result.summary).toBe("");
expect(result.raw).toBe(raw);
});
// ---------------------------------------------------------------------------
// 2. Single :::field extraction
// ---------------------------------------------------------------------------
it("extracts a single :::field block", () => {
const raw = [
"请为你的小说起一个名字:",
"",
':::field{key="title" label="书名"}',
"星河彼岸",
":::",
"",
"很好的名字!",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.fields["title"]).toBe("星河彼岸");
expect(result.textContent).toBe(
["请为你的小说起一个名字:", "", "", "很好的名字!"].join("\n"),
);
expect(result.raw).toBe(raw);
});
// ---------------------------------------------------------------------------
// 3. Multiple fields of different types
// ---------------------------------------------------------------------------
it("extracts multiple fields of different types", () => {
const raw = [
"以下是你的创作信息:",
"",
':::field{key="title" label="书名"}',
"星河彼岸",
":::",
"",
':::field{key="worldPremise" label="世界观" type="textarea"}',
"一个被星际战争撕裂的宇宙",
":::",
"",
':::pick{key="platform" label="目标平台"}',
"- 起点中文网",
"- 番茄小说",
"- 七猫",
":::",
"",
':::number{key="targetChapters" label="目标章数"}',
"300",
":::",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.fields["title"]).toBe("星河彼岸");
expect(result.fields["worldPremise"]).toBe("一个被星际战争撕裂的宇宙");
expect(result.fields["platform"]).toBe("起点中文网");
expect(result.fields["targetChapters"]).toBe("300");
});
// ---------------------------------------------------------------------------
// 4. Nested :::group containing multiple fields
// ---------------------------------------------------------------------------
it("extracts fields nested inside a :::group", () => {
const raw = [
"请确认篇幅设置:",
"",
':::group{label="篇幅"}',
':::number{key="targetChapters" label="目标章数"}',
"300",
":::",
':::number{key="chapterLength" label="每章字数"}',
"3000",
":::",
":::",
"",
"确认无误!",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.fields["targetChapters"]).toBe("300");
expect(result.fields["chapterLength"]).toBe("3000");
// group itself should not appear in textContent
expect(result.textContent).toBe(
["请确认篇幅设置:", "", "", "确认无误!"].join("\n"),
);
});
// ---------------------------------------------------------------------------
// 5. Mixed content: markdown paragraphs interspersed with directives
// ---------------------------------------------------------------------------
it("handles mixed markdown and directives", () => {
const raw = [
"# 创建新书",
"",
"让我们开始吧。首先需要一个书名:",
"",
':::field{key="title" label="书名"}',
"星河彼岸",
":::",
"",
"好的!接下来设定你的世界观:",
"",
':::field{key="worldPremise" label="世界观" type="textarea"}',
"宇宙分裂为光暗两域",
":::",
"",
"让我们继续完善细节。",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.fields["title"]).toBe("星河彼岸");
expect(result.fields["worldPremise"]).toBe("宇宙分裂为光暗两域");
expect(result.textContent).toContain("# 创建新书");
expect(result.textContent).toContain("让我们开始吧。首先需要一个书名:");
expect(result.textContent).toContain("好的!接下来设定你的世界观:");
expect(result.textContent).toContain("让我们继续完善细节。");
expect(result.textContent).not.toContain(":::field");
expect(result.textContent).not.toContain("星河彼岸");
});
// ---------------------------------------------------------------------------
// 6. :::pick extracts first option as default value
// ---------------------------------------------------------------------------
it("extracts first option from :::pick as default value", () => {
const raw = [
':::pick{key="genre" label="题材"}',
"- 玄幻",
"- 仙侠",
"- 都市",
":::",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.fields["genre"]).toBe("玄幻");
});
it("handles :::pick with no options gracefully", () => {
const raw = [
':::pick{key="genre" label="题材"}',
":::",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.fields["genre"]).toBe("");
});
// ---------------------------------------------------------------------------
// 7. Summary generation from field labels
// ---------------------------------------------------------------------------
it("generates summary from field labels", () => {
const raw = [
':::field{key="title" label="书名"}',
"星河彼岸",
":::",
':::field{key="worldPremise" label="世界观"}',
"一个宇宙",
":::",
':::field{key="protagonist" label="主角"}',
"陈风",
":::",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.summary).toBe("确立了书名、世界观和主角");
});
it("generates summary with single field", () => {
const raw = [
':::field{key="title" label="书名"}',
"星河彼岸",
":::",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.summary).toBe("确立了书名");
});
it("generates summary with two fields", () => {
const raw = [
':::field{key="title" label="书名"}',
"星河彼岸",
":::",
':::field{key="worldPremise" label="世界观"}',
"一个宇宙",
":::",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.summary).toBe("确立了书名和世界观");
});
// ---------------------------------------------------------------------------
// 8. Edge case: ::: in code blocks should NOT be parsed as directives
// ---------------------------------------------------------------------------
it("does not parse ::: inside fenced code blocks", () => {
const raw = [
"下面是一个示例:",
"",
"```markdown",
':::field{key="demo" label="示例"}',
"这不是真正的字段",
":::",
"```",
"",
"以上只是演示。",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.fields).toEqual({});
expect(result.textContent).toBe(raw);
});
it("does not parse ::: inside indented code blocks with backtick fences", () => {
const raw = [
"示例代码:",
"",
"````",
':::field{key="demo" label="示例"}',
"不是字段",
":::",
"````",
"",
"结束。",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.fields).toEqual({});
expect(result.textContent).toBe(raw);
});
// ---------------------------------------------------------------------------
// Multi-line field value
// ---------------------------------------------------------------------------
it("extracts multi-line field value from textarea type", () => {
const raw = [
':::field{key="outline" label="大纲" type="textarea"}',
"第一卷:起源",
"第二卷:征途",
"第三卷:终局",
":::",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.fields["outline"]).toBe(
"第一卷:起源\n第二卷:征途\n第三卷:终局",
);
});
// ---------------------------------------------------------------------------
// group label appears in summary
// ---------------------------------------------------------------------------
it("does not include group labels in summary (only leaf fields)", () => {
const raw = [
':::group{label="篇幅设置"}',
':::number{key="chapterCount" label="总章数"}',
"200",
":::",
":::",
].join("\n");
const result = parseDraftDirectives(raw);
// summary should mention "总章数", not "篇幅设置"
expect(result.summary).toBe("确立了总章数");
});
// ---------------------------------------------------------------------------
// Attribute parsing edge cases
// ---------------------------------------------------------------------------
it("handles single-quoted attributes", () => {
const raw = [
":::field{key='title' label='书名'}",
"星河彼岸",
":::",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.fields["title"]).toBe("星河彼岸");
});
it("handles attributes with extra spaces", () => {
const raw = [
':::field{ key="title" label="书名" }',
"星河彼岸",
":::",
].join("\n");
const result = parseDraftDirectives(raw);
expect(result.fields["title"]).toBe("星河彼岸");
});
});
// ---------------------------------------------------------------------------
// Streaming filter
// ---------------------------------------------------------------------------
describe("createDirectiveStreamFilter", () => {
it("passes through pure text unchanged", () => {
const filter = createDirectiveStreamFilter();
expect(filter("你好世界")).toBe("你好世界");
expect(filter("第二段文字")).toBe("第二段文字");
});
it("filters out a complete directive block arriving in one chunk", () => {
const filter = createDirectiveStreamFilter();
const chunk = ':::field{key="title" label="书名"}\n星河彼岸\n:::\n';
expect(filter(chunk)).toBe("");
});
it("filters directive blocks arriving across multiple chunks", () => {
const filter = createDirectiveStreamFilter();
const out1 = filter("欢迎!\n");
expect(out1).toBe("欢迎!\n");
// directive opening arrives
const out2 = filter(':::field{key="title" label="书名"}\n');
expect(out2).toBe("");
// content inside directive
const out3 = filter("星河彼岸\n");
expect(out3).toBe("");
// directive close
const out4 = filter(":::\n");
expect(out4).toBe("");
// back to normal text
const out5 = filter("继续对话。\n");
expect(out5).toBe("继续对话。\n");
});
it("handles nested group directives in stream", () => {
const filter = createDirectiveStreamFilter();
expect(filter("开始\n")).toBe("开始\n");
expect(filter(':::group{label="篇幅"}\n')).toBe("");
expect(filter(':::number{key="ch" label="章数"}\n')).toBe("");
expect(filter("300\n")).toBe("");
expect(filter(":::\n")).toBe(""); // closes number
expect(filter(":::\n")).toBe(""); // closes group
expect(filter("结束\n")).toBe("结束\n");
});
it("does not filter ::: inside code blocks during streaming", () => {
const filter = createDirectiveStreamFilter();
expect(filter("```\n")).toBe("```\n");
expect(filter(':::field{key="x" label="y"}\n')).toBe(
':::field{key="x" label="y"}\n',
);
expect(filter(":::\n")).toBe(":::\n");
expect(filter("```\n")).toBe("```\n");
});
});
@@ -2,10 +2,11 @@ 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 };
return { ...actual, chatCompletion: mockChatCompletion, chatWithTools: mockChatWithTools };
});
const fakePipeline = {
@@ -26,14 +27,28 @@ const fakeState = {
listBooks: vi.fn(async () => []),
};
const MOCK_RESPONSE = {
content: JSON.stringify({
assistantReply: "好的,你想写都市异能,请问主角是什么类型的能力?",
draft: { concept: "都市异能", missingFields: ["title", "genre"], readyToCreate: false },
}),
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", brief: "都市异能题材" }),
},
],
};
describe("chat tool maxTokens forwarding", () => {
beforeEach(() => {
mockChatCompletion.mockResolvedValue({
@@ -92,13 +107,13 @@ describe("chat tool maxTokens forwarding", () => {
});
});
describe("developBookDraft maxTokens not capped", () => {
describe("developBookDraft uses chatWithTools", () => {
beforeEach(() => {
mockChatCompletion.mockResolvedValue(MOCK_RESPONSE);
mockChatCompletion.mockClear();
mockChatWithTools.mockResolvedValue(MOCK_TOOL_RESPONSE);
mockChatWithTools.mockClear();
});
it("does not pass maxTokens to chatCompletion so thinking models are not truncated", async () => {
it("calls chatWithTools with create_book tool and does not pass maxTokens", async () => {
const tools = createInteractionToolsFromDeps(
fakePipeline as never,
fakeState as never,
@@ -106,8 +121,50 @@ describe("developBookDraft maxTokens not capped", () => {
await tools.developBookDraft?.("我想写都市异能", undefined);
expect(mockChatCompletion).toHaveBeenCalledOnce();
const options = mockChatCompletion.mock.calls[0]?.[3] as Record<string, unknown> | 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",
blurb: "都市异能题材",
readyToCreate: true,
}));
expect(details.toolCall).toEqual({
name: "create_book",
arguments: { title: "都市异能", genre: "urban", platform: "tomato", brief: "都市异能题材" },
});
});
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 模型");
});
});
@@ -247,6 +247,7 @@ describe("PipelineRunner", () => {
projectRoot: process.cwd(),
defaultLLMConfig: {
provider: "custom",
service: "custom",
baseUrl: "https://base.example/v1",
apiKey: "base-key",
model: "base-model",
@@ -895,6 +896,7 @@ describe("PipelineRunner", () => {
const result = await runner.writeDraft(bookId);
expect(result.chapterNumber).toBe(1);
console.log("DEBUG warnings:", JSON.stringify(warnings, null, 2));
expect(warnings).toContain(
"当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。",
);
@@ -2279,11 +2281,10 @@ describe("PipelineRunner", () => {
await rm(root, { recursive: true, force: true });
});
it("does not persist chapter files or index entries when state validation errors before save", async () => {
it("degrades to state-degraded when state validation errors instead of aborting", async () => {
const { root, runner, state, bookId } = await createRunnerFixture({
inputGovernanceMode: "legacy",
});
const chaptersDir = join(state.bookDir(bookId), "chapters");
vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
createWriterOutput({
@@ -2302,9 +2303,13 @@ describe("PipelineRunner", () => {
new Error("LLM returned empty response"),
);
await expect(runner.writeNextChapter(bookId)).rejects.toThrow("LLM returned empty response");
await expect(readdir(chaptersDir)).resolves.toEqual([]);
await expect(state.loadChapterIndex(bookId)).resolves.toEqual([]);
const result = await runner.writeNextChapter(bookId);
expect(result.status).toBe("state-degraded");
// Chapter should be saved (content is fine, only truth files are degraded)
const index = await state.loadChapterIndex(bookId);
expect(index).toHaveLength(1);
expect(index[0]!.status).toBe("state-degraded");
await rm(root, { recursive: true, force: true });
});
+222 -143
View File
@@ -1,16 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type OpenAI from "openai";
import type { AssistantMessage, Model, Api } from "@mariozechner/pi-ai";
import {
__resetFixedTemperatureWarnings,
chatCompletion,
type LLMClient,
} from "../llm/provider.js";
const ZERO_USAGE = {
prompt_tokens: 11,
completion_tokens: 7,
total_tokens: 18,
} as const;
// ── Mock @mariozechner/pi-ai ──────────────────────────────────────────────────
// We intercept streamSimple so tests don't hit the network.
const mockStreamSimple = vi.fn();
vi.mock("@mariozechner/pi-ai", async (importOriginal) => {
const original = await importOriginal<typeof import("@mariozechner/pi-ai")>();
return {
...original,
streamSimple: (...args: unknown[]) => mockStreamSimple(...args),
};
});
// ── Helpers ───────────────────────────────────────────────────────────────────
const MOCK_USAGE = {
input: 11,
output: 7,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 18,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
function makeAssistantMessage(text: string): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "openai-completions" as Api,
provider: "openai",
model: "test-model",
usage: MOCK_USAGE,
stopReason: "stop",
timestamp: Date.now(),
};
}
/** Builds an async iterable that emits the given events. */
function makeEventStream(
events: Array<Record<string, unknown>>,
): AsyncIterable<Record<string, unknown>> {
return {
[Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
let i = 0;
return {
async next() {
if (i < events.length) return { value: events[i++]!, done: false };
return { value: undefined as unknown as Record<string, unknown>, done: true };
},
};
},
};
}
/** Stream that emits one text_delta and then done. */
function makeTextStream(text: string): AsyncIterable<Record<string, unknown>> {
const msg = makeAssistantMessage(text);
return makeEventStream([
{ type: "text_delta", contentIndex: 0, delta: text, partial: msg },
{ type: "done", reason: "stop", message: msg },
]);
}
/** Stream that emits only done with empty content. */
function makeEmptyStream(): AsyncIterable<Record<string, unknown>> {
const msg = makeAssistantMessage("");
return makeEventStream([
{ type: "done", reason: "stop", message: msg },
]);
}
/** Stream that throws immediately. */
function makeErrorStream(message: string): AsyncIterable<Record<string, unknown>> {
return {
[Symbol.asyncIterator](): AsyncIterator<Record<string, unknown>> {
return {
async next() {
throw new Error(message);
},
};
},
};
}
const MOCK_PI_MODEL: Model<Api> = {
id: "test-model",
name: "test-model",
api: "openai-completions",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
};
function makeClient(temperature = 0.7, extra: Partial<LLMClient> = {}): LLMClient {
return {
provider: "openai",
apiFormat: "chat",
stream: true,
_piModel: MOCK_PI_MODEL,
_apiKey: "test-key",
defaults: {
temperature,
maxTokens: 512,
thinkingBudget: 0,
maxTokensCap: null,
extra: {},
},
...extra,
};
}
async function captureError(task: Promise<unknown>): Promise<Error> {
try {
@@ -21,169 +130,139 @@ async function captureError(task: Promise<unknown>): Promise<Error> {
throw new Error("Expected promise to reject");
}
describe("chatCompletion stream fallback", () => {
it("falls back to sync chat completion when streamed chat returns no chunks", async () => {
const create = vi.fn()
.mockResolvedValueOnce({
async *[Symbol.asyncIterator](): AsyncIterableIterator<unknown> {
return;
},
})
.mockResolvedValueOnce({
choices: [{ message: { content: "fallback content" } }],
usage: ZERO_USAGE,
});
// ── Tests ─────────────────────────────────────────────────────────────────────
const client: LLMClient = {
provider: "openai",
apiFormat: "chat",
stream: true,
_openai: {
chat: {
completions: {
create,
},
},
} as unknown as OpenAI,
defaults: {
temperature: 0.7,
maxTokens: 512,
thinkingBudget: 0, maxTokensCap: null,
extra: {},
},
};
describe("chatCompletion via pi-ai", () => {
beforeEach(() => {
mockStreamSimple.mockReset();
});
it("returns text content from a successful stream", async () => {
mockStreamSimple.mockReturnValue(makeTextStream("hello world"));
const client = makeClient();
const result = await chatCompletion(client, "test-model", [
{ role: "user", content: "ping" },
]);
expect(result.content).toBe("fallback content");
expect(result.usage).toEqual({
promptTokens: 11,
completionTokens: 7,
totalTokens: 18,
});
expect(create).toHaveBeenCalledTimes(2);
expect(create.mock.calls[0]?.[0]).toMatchObject({ stream: true });
expect(create.mock.calls[1]?.[0]).toMatchObject({ stream: false });
expect(result.content).toBe("hello world");
expect(result.usage.promptTokens).toBe(11);
expect(result.usage.completionTokens).toBe(7);
expect(result.usage.totalTokens).toBe(18);
expect(mockStreamSimple).toHaveBeenCalledOnce();
});
it("does not blindly suggest stream false for generic 400 errors", async () => {
const create = vi.fn().mockRejectedValue(new Error("400 Bad Request"));
it("throws when stream produces no text content", async () => {
mockStreamSimple.mockReturnValue(makeEmptyStream());
const client: LLMClient = {
provider: "openai",
apiFormat: "chat",
stream: false,
_openai: {
chat: {
completions: {
create,
},
},
} as unknown as OpenAI,
defaults: {
temperature: 0.7,
maxTokens: 512,
thinkingBudget: 0, maxTokensCap: null,
extra: {},
},
};
const client = makeClient();
const error = await captureError(
chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
);
const error = await captureError(chatCompletion(client, "test-model", [
{ role: "user", content: "ping" },
]));
expect(error.message).toContain("empty response");
});
it("wraps 400 API errors with a user-friendly message", async () => {
mockStreamSimple.mockReturnValue(makeErrorStream("400 Bad Request"));
const client = makeClient();
const error = await captureError(
chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
);
expect(error.message).toContain("API 返回 400");
expect(error.message).not.toContain("\"stream\": false");
expect(error.message).toContain("检查提供方文档");
});
it("reports when sync fallback is rejected because provider requires streaming", async () => {
const create = vi.fn()
.mockResolvedValueOnce({
async *[Symbol.asyncIterator](): AsyncIterableIterator<unknown> {
return;
},
})
.mockRejectedValueOnce(new Error("400 {\"detail\":\"Stream must be set to true\"}"));
it("wraps 401 errors with an unauthorized message", async () => {
mockStreamSimple.mockReturnValue(makeErrorStream("401 Unauthorized"));
const client: LLMClient = {
provider: "openai",
apiFormat: "chat",
stream: true,
_openai: {
chat: {
completions: {
create,
},
},
} as unknown as OpenAI,
defaults: {
temperature: 0.7,
maxTokens: 512,
thinkingBudget: 0, maxTokensCap: null,
extra: {},
},
};
const client = makeClient();
const error = await captureError(
chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
);
const error = await captureError(chatCompletion(client, "test-model", [
{ role: "user", content: "ping" },
expect(error.message).toContain("API 返回 401");
});
it("wraps connection errors with a friendly message", async () => {
mockStreamSimple.mockReturnValue(makeErrorStream("fetch failed: ECONNREFUSED"));
const client = makeClient();
const error = await captureError(
chatCompletion(client, "test-model", [{ role: "user", content: "ping" }]),
);
expect(error.message).toContain("无法连接到 API 服务");
});
it("passes temperature and maxTokens to streamSimple", async () => {
mockStreamSimple.mockReturnValue(makeTextStream("ok"));
const client = makeClient(0.5);
await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }], {
temperature: 0.3,
maxTokens: 256,
});
const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
expect(opts.temperature).toBe(0.3);
expect(opts.maxTokens).toBe(256);
});
it("uses client defaults when no per-call overrides are provided", async () => {
mockStreamSimple.mockReturnValue(makeTextStream("ok"));
const client = makeClient(0.8);
await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }]);
const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
expect(opts.temperature).toBe(0.8);
expect(opts.maxTokens).toBe(512);
});
it("calls onTextDelta for each text chunk", async () => {
const msg = makeAssistantMessage("abc");
mockStreamSimple.mockReturnValue(makeEventStream([
{ type: "text_delta", contentIndex: 0, delta: "a", partial: msg },
{ type: "text_delta", contentIndex: 0, delta: "b", partial: msg },
{ type: "text_delta", contentIndex: 0, delta: "c", partial: msg },
{ type: "done", reason: "stop", message: msg },
]));
expect(create).toHaveBeenCalledTimes(2);
expect(create.mock.calls[0]?.[0]).toMatchObject({ stream: true });
expect(create.mock.calls[1]?.[0]).toMatchObject({ stream: false });
expect(error.message).toContain("stream:true");
expect(error.message).not.toContain("\"stream\": false");
const deltas: string[] = [];
const client = makeClient();
await chatCompletion(client, "test-model", [{ role: "user", content: "hi" }], {
onTextDelta: (d) => deltas.push(d),
});
expect(deltas).toEqual(["a", "b", "c"]);
});
});
describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
beforeEach(() => {
__resetFixedTemperatureWarnings();
mockStreamSimple.mockReset();
mockStreamSimple.mockReturnValue(makeTextStream("ok"));
});
function makeSyncClient(create: ReturnType<typeof vi.fn>, temperature: number): LLMClient {
return {
provider: "openai",
apiFormat: "chat",
stream: false,
_openai: {
chat: { completions: { create } },
} as unknown as OpenAI,
defaults: {
temperature,
maxTokens: 512,
thinkingBudget: 0,
maxTokensCap: null,
extra: {},
},
};
}
const OK_RESPONSE = {
choices: [{ message: { content: "ok" } }],
usage: ZERO_USAGE,
};
it("forces temperature=1 for kimi-k2.5 even when client default is 0.7", async () => {
const create = vi.fn().mockResolvedValue(OK_RESPONSE);
const client = makeSyncClient(create, 0.7);
const client = makeClient(0.7);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
expect(create).toHaveBeenCalledTimes(1);
expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
expect(opts.temperature).toBe(1);
expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0]?.[0]).toContain("kimi-k2.5");
warn.mockRestore();
});
it("clamps per-call temperature override (0.3) to 1 for kimi-k2.5", async () => {
const create = vi.fn().mockResolvedValue(OK_RESPONSE);
const client = makeSyncClient(create, 0.7);
const client = makeClient(0.7);
vi.spyOn(console, "warn").mockImplementation(() => {});
await chatCompletion(
@@ -193,12 +272,12 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
{ temperature: 0.3 },
);
expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
expect(opts.temperature).toBe(1);
});
it("only warns once per model name across multiple calls", async () => {
const create = vi.fn().mockResolvedValue(OK_RESPONSE);
const client = makeSyncClient(create, 0.7);
const client = makeClient(0.7);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "a" }]);
@@ -210,20 +289,19 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
});
it("also clamps any model name containing 'thinking'", async () => {
const create = vi.fn().mockResolvedValue(OK_RESPONSE);
const client = makeSyncClient(create, 0.5);
const client = makeClient(0.5);
vi.spyOn(console, "warn").mockImplementation(() => {});
await chatCompletion(client, "kimi-thinking-preview", [
{ role: "user", content: "hi" },
]);
expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
expect(opts.temperature).toBe(1);
});
it("leaves regular models untouched (no clamp, no warning)", async () => {
const create = vi.fn().mockResolvedValue(OK_RESPONSE);
const client = makeSyncClient(create, 0.7);
const client = makeClient(0.7);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await chatCompletion(
@@ -233,19 +311,20 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
{ temperature: 0.3 },
);
expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 0.3 });
const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
expect(opts.temperature).toBe(0.3);
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
it("does not warn when requested temperature is already 1", async () => {
const create = vi.fn().mockResolvedValue(OK_RESPONSE);
const client = makeSyncClient(create, 1);
const client = makeClient(1);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await chatCompletion(client, "kimi-k2.5", [{ role: "user", content: "hi" }]);
expect(create.mock.calls[0]?.[0]).toMatchObject({ temperature: 1 });
const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
expect(opts.temperature).toBe(1);
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
@@ -0,0 +1,95 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { loadSecrets, saveSecrets, getServiceApiKey } from "../llm/secrets.js";
import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("secrets", () => {
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "inkos-secrets-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
describe("loadSecrets", () => {
it("returns empty when .inkos/secrets.json does not exist", async () => {
const secrets = await loadSecrets(root);
expect(secrets).toEqual({ services: {} });
});
it("reads existing secrets file", async () => {
await mkdir(join(root, ".inkos"), { recursive: true });
await writeFile(
join(root, ".inkos", "secrets.json"),
JSON.stringify({ services: { moonshot: { apiKey: "sk-test" } } }),
);
const secrets = await loadSecrets(root);
expect(secrets.services.moonshot.apiKey).toBe("sk-test");
});
});
describe("saveSecrets", () => {
it("creates .inkos dir and writes secrets file", async () => {
await saveSecrets(root, {
services: { deepseek: { apiKey: "sk-deep" } },
});
const raw = await readFile(join(root, ".inkos", "secrets.json"), "utf-8");
const parsed = JSON.parse(raw);
expect(parsed.services.deepseek.apiKey).toBe("sk-deep");
});
it("overwrites existing secrets file", async () => {
await mkdir(join(root, ".inkos"), { recursive: true });
await writeFile(
join(root, ".inkos", "secrets.json"),
JSON.stringify({ services: { old: { apiKey: "old-key" } } }),
);
await saveSecrets(root, {
services: { new: { apiKey: "new-key" } },
});
const secrets = await loadSecrets(root);
expect(secrets.services.new.apiKey).toBe("new-key");
expect(secrets.services.old).toBeUndefined();
});
});
describe("getServiceApiKey", () => {
it("returns key from secrets.json first", async () => {
await mkdir(join(root, ".inkos"), { recursive: true });
await writeFile(
join(root, ".inkos", "secrets.json"),
JSON.stringify({ services: { moonshot: { apiKey: "sk-from-file" } } }),
);
const key = await getServiceApiKey(root, "moonshot");
expect(key).toBe("sk-from-file");
});
it("falls back to environment variable", async () => {
vi.stubEnv("MOONSHOT_API_KEY", "sk-from-env");
const key = await getServiceApiKey(root, "moonshot");
expect(key).toBe("sk-from-env");
vi.unstubAllEnvs();
});
it("returns null when neither secrets nor env exists", async () => {
const key = await getServiceApiKey(root, "moonshot");
expect(key).toBeNull();
});
it("handles custom service with colon key format", async () => {
await mkdir(join(root, ".inkos"), { recursive: true });
await writeFile(
join(root, ".inkos", "secrets.json"),
JSON.stringify({
services: { "custom:内网GPT": { apiKey: "sk-custom" } },
}),
);
const key = await getServiceApiKey(root, "custom:内网GPT");
expect(key).toBe("sk-custom");
});
});
});
@@ -0,0 +1,122 @@
// packages/core/src/__tests__/service-resolver.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
// Models that exist in pi-ai's built-in registry (simulated)
const KNOWN_MODELS = new Set(["gpt-4o", "kimi-k2.5"]);
// Mock pi-ai's getModel — returns undefined for models not in registry (like the real implementation)
vi.mock("@mariozechner/pi-ai", () => ({
getModel: vi.fn((provider: string, modelId: string) => {
if (!KNOWN_MODELS.has(modelId)) return undefined;
return {
id: modelId,
name: modelId,
api: "openai-completions",
provider,
baseUrl: "https://api.openai.com/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 16384,
};
}),
getEnvApiKey: vi.fn(() => undefined),
}));
import { resolveServiceModel } from "../llm/service-resolver.js";
describe("resolveServiceModel", () => {
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "inkos-resolver-"));
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
vi.unstubAllEnvs();
});
it("resolves built-in service with key from secrets", async () => {
await mkdir(join(root, ".inkos"), { recursive: true });
await writeFile(
join(root, ".inkos", "secrets.json"),
JSON.stringify({ services: { moonshot: { apiKey: "sk-moon" } } }),
);
const result = await resolveServiceModel("moonshot", "kimi-k2.5", root);
expect(result.model.id).toBe("kimi-k2.5");
expect(result.apiKey).toBe("sk-moon");
expect(result.writingTemperature).toBe(1.0);
expect(result.temperatureRange).toEqual([0, 1]);
});
it("resolves deepseek with correct temperature", async () => {
await mkdir(join(root, ".inkos"), { recursive: true });
await writeFile(
join(root, ".inkos", "secrets.json"),
JSON.stringify({ services: { deepseek: { apiKey: "sk-deep" } } }),
);
const result = await resolveServiceModel("deepseek", "deepseek-chat", root);
expect(result.apiKey).toBe("sk-deep");
expect(result.writingTemperature).toBe(1.5);
expect(result.temperatureRange).toEqual([0, 2]);
});
it("constructs model from preset when getModel returns undefined", async () => {
await mkdir(join(root, ".inkos"), { recursive: true });
await writeFile(
join(root, ".inkos", "secrets.json"),
JSON.stringify({ services: { deepseek: { apiKey: "sk-deep" } } }),
);
// "deepseek-chat" is NOT in KNOWN_MODELS, so getModel returns undefined
const result = await resolveServiceModel("deepseek", "deepseek-chat", root);
expect(result.model).toBeDefined();
expect(result.model.id).toBe("deepseek-chat");
expect(result.model.api).toBe("openai-completions");
expect(result.model.baseUrl).toBe("https://api.deepseek.com");
expect(result.model.provider).toBe("openai");
expect(result.apiKey).toBe("sk-deep");
});
it("falls back to env var when no secrets file", async () => {
vi.stubEnv("DEEPSEEK_API_KEY", "sk-env");
const result = await resolveServiceModel("deepseek", "deepseek-chat", root);
expect(result.apiKey).toBe("sk-env");
});
it("throws when no key found", async () => {
await expect(
resolveServiceModel("moonshot", "kimi-k2.5", root),
).rejects.toThrow(/API key/i);
});
it("resolves custom service with baseUrl", async () => {
await mkdir(join(root, ".inkos"), { recursive: true });
await writeFile(
join(root, ".inkos", "secrets.json"),
JSON.stringify({ services: { "custom:内网GPT": { apiKey: "sk-corp" } } }),
);
const result = await resolveServiceModel(
"custom:内网GPT",
"gpt-4o",
root,
"https://llm.internal.corp/v1",
);
expect(result.apiKey).toBe("sk-corp");
expect(result.model.id).toBe("gpt-4o");
});
});
@@ -678,6 +678,22 @@ describe("StateManager", () => {
expect(String(rejected[0]?.reason)).toMatch(/is locked/);
});
it("reclaims same-process stale lock when no active write is in progress", async () => {
await mkdir(manager.bookDir("lock-book-self"), { recursive: true });
const lockPath = join(manager.bookDir("lock-book-self"), ".write.lock");
// Simulate a stale lock left by our own process (e.g. after a failed pipeline)
await writeFile(lockPath, `pid:${process.pid} ts:${Date.now() - 60000}`, "utf-8");
// Should auto-reclaim since our process knows it's not actively writing this book
const release = await manager.acquireBookLock("lock-book-self");
expect(typeof release).toBe("function");
const lockData = await readFile(lockPath, "utf-8");
expect(lockData).toContain(`pid:${process.pid}`);
await release();
});
it("reclaims a stale lock when the recorded pid is no longer alive", async () => {
await mkdir(manager.bookDir("lock-book-5"), { recursive: true });
const lockPath = join(manager.bookDir("lock-book-5"), ".write.lock");
@@ -55,6 +55,36 @@ describe("StateValidatorAgent", () => {
});
});
it("passes maxTokens large enough for thinking models to chat()", async () => {
const agent = new StateValidatorAgent({
client: {
provider: "openai",
apiFormat: "chat",
stream: false,
defaults: {
temperature: 0.7,
maxTokens: 8192,
thinkingBudget: 0,
maxTokensCap: null,
extra: {},
},
},
model: "test-model",
projectRoot: process.cwd(),
});
const chatSpy = vi.spyOn(
agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> },
"chat",
).mockResolvedValue({ content: "PASS", usage: ZERO_USAGE });
await agent.validate("Body.", 1, "old", "new state", "old hooks", "new hooks", "zh");
const options = chatSpy.mock.calls[0]?.[1] as { maxTokens?: number } | undefined;
// Must not hardcode a small value like 2048 that starves thinking models
expect(options?.maxTokens).toBeUndefined();
});
it("throws when the validator model returns an empty response", async () => {
const agent = new StateValidatorAgent({
client: {
@@ -0,0 +1,57 @@
import { describe, it, expect } from "vitest";
import { resolveServicePreset, clampTemperature, getWritingTemperature } from "../llm/service-presets.js";
describe("temperature constraints per service", () => {
it("moonshot has range [0, 1] and writingTemperature 1.0", () => {
const preset = resolveServicePreset("moonshot");
expect(preset?.temperatureRange).toEqual([0, 1]);
expect(preset?.writingTemperature).toBe(1.0);
});
it("deepseek has range [0, 2] and writingTemperature 1.5", () => {
const preset = resolveServicePreset("deepseek");
expect(preset?.temperatureRange).toEqual([0, 2]);
expect(preset?.writingTemperature).toBe(1.5);
});
it("anthropic has range [0, 1] and writingTemperature 1.0", () => {
const preset = resolveServicePreset("anthropic");
expect(preset?.temperatureRange).toEqual([0, 1]);
expect(preset?.writingTemperature).toBe(1.0);
});
it("openai has range [0, 2] and writingTemperature 1.0", () => {
const preset = resolveServicePreset("openai");
expect(preset?.temperatureRange).toEqual([0, 2]);
expect(preset?.writingTemperature).toBe(1.0);
});
it("zhipu has range [0, 1]", () => {
const preset = resolveServicePreset("zhipu");
expect(preset?.temperatureRange).toEqual([0, 1]);
});
it("bailian has range [0, 2]", () => {
const preset = resolveServicePreset("bailian");
expect(preset?.temperatureRange).toEqual([0, 2]);
});
it("minimax has range [0, 2]", () => {
const preset = resolveServicePreset("minimax");
expect(preset?.temperatureRange).toEqual([0, 2]);
});
it("clampTemperature respects service range", () => {
expect(clampTemperature("moonshot", 1.5)).toBe(1.0);
expect(clampTemperature("moonshot", 0.7)).toBe(0.7);
expect(clampTemperature("deepseek", 1.5)).toBe(1.5);
expect(clampTemperature("deepseek", 2.5)).toBe(2.0);
expect(clampTemperature("unknown-service", 1.5)).toBe(1.5);
});
it("getWritingTemperature returns service-specific value", () => {
expect(getWritingTemperature("moonshot")).toBe(1.0);
expect(getWritingTemperature("deepseek")).toBe(1.5);
expect(getWritingTemperature("anthropic")).toBe(1.0);
});
});
+304
View File
@@ -0,0 +1,304 @@
import { Agent } from "@mariozechner/pi-agent-core";
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
import { streamSimple, getModel, getEnvApiKey } from "@mariozechner/pi-ai";
import type { Model, Api, AssistantMessage, UserMessage } from "@mariozechner/pi-ai";
import type { PipelineRunner } from "../pipeline/runner.js";
import { buildAgentSystemPrompt } from "./agent-system-prompt.js";
import {
createSubAgentTool,
createReadTool,
createEditTool,
createGrepTool,
createLsTool,
} from "./agent-tools.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AgentSessionConfig {
/** Unique session identifier (typically the BookSession id). */
sessionId: string;
/** Book ID, or null if in "new book" mode. */
bookId: string | null;
/** Language for the system prompt. */
language: string;
/** PipelineRunner for sub-agent tool delegation. */
pipeline: PipelineRunner;
/** Project root directory (books/ lives under this). */
projectRoot: string;
/** pi-ai Model to use, or provider+modelId to resolve via getModel. */
model: Model<Api> | { provider: string; modelId: string };
/** Optional API key. When omitted, falls back to env-based key lookup. */
apiKey?: string;
/** Optional listener for streaming events (for SSE forwarding). */
onEvent?: (event: AgentEvent) => void;
}
export interface AgentSessionResult {
/** Extracted text from the final assistant message. */
responseText: string;
/** Full conversation history for persistence. */
messages: Array<{ role: string; content: string; thinking?: string }>;
}
// ---------------------------------------------------------------------------
// Cache
// ---------------------------------------------------------------------------
interface CachedAgent {
agent: Agent;
lastActive: number;
}
const agentCache = new Map<string, CachedAgent>();
/** TTL for cached agents: 5 minutes. */
const CACHE_TTL_MS = 5 * 60 * 1000;
/** Cleanup interval handle (lazy-started). */
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
function ensureCleanupTimer(): void {
if (cleanupTimer) return;
cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [id, entry] of agentCache) {
if (now - entry.lastActive > CACHE_TTL_MS) {
agentCache.delete(id);
}
}
// Stop the timer when nothing left to watch.
if (agentCache.size === 0 && cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
}, 60_000); // run every 60 s
// Allow the process to exit even if this timer is alive.
if (cleanupTimer && typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
cleanupTimer.unref();
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function resolveModel(spec: AgentSessionConfig["model"]): Model<Api> {
if (!spec) {
throw new Error("Model is required but was undefined. Check LLM configuration.");
}
if (typeof spec === "object" && "id" in spec && "api" in spec) {
// Already a Model object.
return spec as Model<Api>;
}
const { provider, modelId } = spec as { provider: string; modelId: string };
if (!provider || !modelId) {
throw new Error(`Invalid model spec: provider=${provider}, modelId=${modelId}`);
}
return getModel(provider as any, modelId as any);
}
/**
* Extract readable text from an AssistantMessage's content array.
* Filters out tool-call blocks; concatenates text blocks.
*/
function extractTextFromAssistant(msg: AssistantMessage): string {
return msg.content
.filter((c): c is { type: "text"; text: string } => c.type === "text")
.map((c) => c.text)
.join("");
}
/**
* Extract thinking/reasoning text from an AssistantMessage's content array.
*/
function extractThinkingFromAssistant(msg: AssistantMessage): string {
return msg.content
.filter((c: any) => c.type === "thinking")
.map((c: any) => c.thinking ?? "")
.join("");
}
/**
* Convert plain `{ role, content }` messages (from BookSession disk storage)
* back into pi-agent AgentMessage format so they can be loaded into an Agent.
*/
function plainToAgentMessages(
plain: Array<{ role: string; content: string }>,
): AgentMessage[] {
return plain.map((m) => {
const ts = Date.now();
if (m.role === "user") {
return { role: "user", content: m.content, timestamp: ts } satisfies UserMessage;
}
// For stored assistant messages we only have the text.
// Re-wrap as a minimal AssistantMessage with a single TextContent.
return {
role: "assistant",
content: [{ type: "text", text: m.content }],
api: "anthropic-messages",
provider: "anthropic",
model: "unknown",
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: ts,
} satisfies AssistantMessage;
});
}
/**
* Flatten the Agent's in-memory messages to plain `{ role, content }` pairs
* suitable for BookSession persistence.
*/
function agentMessagesToPlain(
messages: AgentMessage[],
): Array<{ role: string; content: string; thinking?: string }> {
const out: Array<{ role: string; content: string; thinking?: string }> = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object" || !("role" in msg)) continue;
const m = msg as { role: string; [k: string]: any };
if (m.role === "user") {
const content = typeof m.content === "string"
? m.content
: Array.isArray(m.content)
? m.content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text)
.join("")
: "";
if (content) out.push({ role: "user", content });
} else if (m.role === "assistant") {
const text = extractTextFromAssistant(m as AssistantMessage);
const thinking = extractThinkingFromAssistant(m as AssistantMessage);
if (text || thinking) {
const entry: { role: string; content: string; thinking?: string } = { role: "assistant", content: text };
if (thinking) entry.thinking = thinking;
out.push(entry);
}
}
// ToolResult messages are internal; skip them for persistence.
}
return out;
}
// ---------------------------------------------------------------------------
// Main entry point
// ---------------------------------------------------------------------------
/**
* Run a single conversation turn within a cached Agent session.
*
* If the session already exists in the cache, reuses the Agent (with its full
* in-memory message history including tool calls). Otherwise creates a new
* Agent, optionally restoring messages from `initialMessages`.
*/
export async function runAgentSession(
config: AgentSessionConfig,
userMessage: string,
initialMessages?: Array<{ role: string; content: string }>,
): Promise<AgentSessionResult> {
const { sessionId, bookId, language, pipeline, projectRoot, onEvent } = config;
// ----- Resolve or create Agent -----
let cached = agentCache.get(sessionId);
if (cached) {
// Check if model changed — evict and rebuild if so
const currentModelId = (cached.agent.state.model as any)?.id;
const newModelId = typeof config.model === 'object' && 'id' in config.model
? (config.model as any).id
: undefined;
if (currentModelId && newModelId && currentModelId !== newModelId) {
// Preserve conversation messages for re-injection
const preservedMessages = agentMessagesToPlain(cached.agent.state.messages);
agentCache.delete(sessionId);
cached = undefined;
// Pass preserved messages as initialMessages if none were provided
if (!initialMessages || initialMessages.length === 0) {
initialMessages = preservedMessages;
}
}
}
if (!cached) {
const model = resolveModel(config.model);
const agent = new Agent({
initialState: {
model,
systemPrompt: buildAgentSystemPrompt(bookId, language),
tools: [
createSubAgentTool(pipeline, bookId),
createReadTool(projectRoot),
createEditTool(projectRoot),
createGrepTool(projectRoot),
createLsTool(projectRoot),
],
},
streamFn: streamSimple,
getApiKey: (provider: string) => {
if (config.apiKey) return config.apiKey;
return getEnvApiKey(provider);
},
});
// Restore prior conversation if provided.
if (initialMessages && initialMessages.length > 0) {
agent.state.messages = plainToAgentMessages(initialMessages);
}
cached = { agent, lastActive: Date.now() };
agentCache.set(sessionId, cached);
ensureCleanupTimer();
}
cached.lastActive = Date.now();
const { agent } = cached;
// ----- Subscribe to events (for SSE streaming to frontend) -----
let unsubscribe: (() => void) | undefined;
if (onEvent) {
unsubscribe = agent.subscribe((event: AgentEvent) => {
onEvent(event);
});
}
// ----- Execute the turn -----
try {
await agent.prompt(userMessage);
} finally {
unsubscribe?.();
}
// ----- Extract result -----
const allMessages = agent.state.messages;
const responseText = extractResponseText(allMessages);
const plainMessages = agentMessagesToPlain(allMessages);
return { responseText, messages: plainMessages };
}
/**
* Walk backward through messages to find the last assistant message and
* extract its text content.
*/
function extractResponseText(messages: AgentMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg && typeof msg === "object" && "role" in msg && (msg as any).role === "assistant") {
return extractTextFromAssistant(msg as AssistantMessage);
}
}
return "";
}
// ---------------------------------------------------------------------------
// Cache management
// ---------------------------------------------------------------------------
/** Manually evict a cached Agent session. */
export function evictAgentCache(sessionId: string): boolean {
return agentCache.delete(sessionId);
}
@@ -0,0 +1,98 @@
export function buildAgentSystemPrompt(bookId: string | null, language: string): string {
const isZh = language === "zh";
if (!bookId) {
return isZh
? `你是 InkOS 建书助手。你的任务是帮用户从零开始创建一本新书。
## 工作流程
1. **收集信息**(对话阶段)— 通过自然对话逐步了解:
- 题材/类型(如玄幻、都市、悬疑、言情等)
- 目标平台(番茄小说、起点中文网、飞卢等)
- 世界观设定(什么样的世界?有什么特殊规则?)
- 主角设定(谁?什么背景?什么性格?)
- 核心冲突(主线矛盾是什么?)
- 写作语言(中文/English
2. **确认建书**(调用阶段)— 当信息足够时,调用 sub_agent 工具委托 architect 子智能体建书:
- instruction 中包含收集到的所有信息(题材、世界观、主角、冲突等)
- architect 会生成完整的 foundation(世界观设定、卷纲规划、叙事规则等)
## 对话风格
- 每次只问一个问题,不要一次问太多
- 用户回答模糊时,给出 2-3 个具体选项引导
- 当信息基本齐了,主动提议建书,不要无限追问
- 保持简短、自然
- **不要在回复中添加表情符号**`
: `You are the InkOS book creation assistant. Help the user create a new book from scratch.
## Workflow
1. **Collect information** — Through conversation, gradually learn:
- Genre (fantasy, urban, mystery, romance, etc.)
- Target platform
- World setting
- Protagonist
- Core conflict
- Writing language
2. **Create book** — When you have enough info, call the sub_agent tool with agent="architect":
- Include all collected info in the instruction
- The architect will generate the complete foundation
## Style
- Ask one question at a time
- Offer 2-3 concrete options when the user is vague
- Proactively suggest creating the book when enough info is collected
- Keep responses brief and natural
- **Do NOT use emoji in your responses**`;
}
return isZh
? `你是 InkOS 写作助手,当前正在处理书籍「${bookId}」。
## 可用工具
- **sub_agent** — 委托子智能体执行重操作:
- agent="writer" 写下一章
- agent="auditor" 审计章节质量
- agent="reviser" 修订章节
- agent="exporter" 导出书籍
- **read** — 读取书籍的设定文件或章节内容
- **edit** — 编辑设定文件(如修改角色名、调整世界观)
- **grep** — 搜索内容(如"哪一章提到了某个角色")
- **ls** — 列出文件或章节
## 使用原则
- 写章节、修订、审计等重操作 → 使用 sub_agent 委托对应子智能体
- 用户问设定相关问题 → 先用 read 读取对应文件再回答
- 用户想做小修改(改名字、调设定)→ 用 edit 直接修改
- 其他情况 → 直接对话回答
- **注意:不要调用 architect,当前已有书籍,不需要建书**
- **不要在回复中添加表情符号**`
: `You are the InkOS writing assistant, working on book "${bookId}".
## Available Tools
- **sub_agent** — Delegate to sub-agents:
- agent="writer" for writing next chapter
- agent="auditor" for chapter quality audit
- agent="reviser" for chapter revision
- agent="exporter" for book export
- **read** — Read truth files or chapter content
- **edit** — Edit truth files (rename characters, adjust world settings)
- **grep** — Search content across chapters
- **ls** — List files or chapters
## Guidelines
- Use sub_agent for heavy operations (writing, revision, auditing)
- Use read/edit for settings inquiries and small changes
- Chat directly for other questions
- **Do NOT call architect — a book already exists**
- **Do NOT use emoji in your responses**`;
}
+338
View File
@@ -0,0 +1,338 @@
import { Type, type Static } from "@mariozechner/pi-ai";
import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
import type { PipelineRunner } from "../pipeline/runner.js";
import type { ReviseMode } from "../agents/reviser.js";
import { readFile, writeFile, readdir, stat } from "node:fs/promises";
import { join, normalize, resolve } from "node:path";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function textResult(text: string): AgentToolResult<undefined> {
return { content: [{ type: "text", text }], details: undefined };
}
/**
* Resolve a user-supplied relative path against the books root and guard
* against path-traversal (../ etc.).
*/
function safeBooksPath(booksRoot: string, relativePath: string): string {
const resolved = resolve(booksRoot, normalize(relativePath));
if (!resolved.startsWith(booksRoot)) {
throw new Error(`Path traversal blocked: ${relativePath}`);
}
return resolved;
}
// ---------------------------------------------------------------------------
// 1. SubAgentTool (sub_agent)
// ---------------------------------------------------------------------------
const SubAgentParams = Type.Object({
agent: Type.Union([
Type.Literal("architect"),
Type.Literal("writer"),
Type.Literal("auditor"),
Type.Literal("reviser"),
Type.Literal("exporter"),
]),
instruction: Type.String({ description: "Natural language instruction from the main Agent" }),
bookId: Type.Optional(Type.String({ description: "Book ID — required for all agents except architect" })),
});
export function createSubAgentTool(pipeline: PipelineRunner, activeBookId: string | null): AgentTool<typeof SubAgentParams> {
return {
name: "sub_agent",
description:
"Delegate a heavy operation to a specialised sub-agent. " +
"Use agent='architect' to initialise a new book, 'writer' to write the next chapter, " +
"'auditor' to audit quality, 'reviser' to revise a chapter, 'exporter' to export.",
label: "Sub-Agent",
parameters: SubAgentParams,
async execute(
_toolCallId: string,
params: Static<typeof SubAgentParams>,
_signal?: AbortSignal,
onUpdate?: AgentToolUpdateCallback,
): Promise<AgentToolResult<undefined>> {
const { agent, instruction, bookId } = params;
const progress = (msg: string) => {
onUpdate?.(textResult(msg));
};
try {
switch (agent) {
case "architect": {
// architect 只在没有书的时候可用(建书流程)
if (activeBookId) {
return textResult("当前已有书籍,不需要建书。如果你想创建新书,请先回到首页。");
}
const id = bookId || `book-${Date.now().toString(36)}`;
progress(`Starting architect for book "${id}"...`);
await pipeline.initBook(
{ id, genre: "general", title: "", language: "zh" } as any,
{ externalContext: instruction },
);
progress(`Architect finished — book "${id}" foundation created.`);
return textResult(`Book "${id}" initialised successfully. Foundation files are ready.`);
}
case "writer": {
if (!bookId) return textResult("Error: bookId is required for the writer agent.");
progress(`Writing next chapter for "${bookId}"...`);
const result = await pipeline.writeNextChapter(bookId);
progress(`Writer finished chapter for "${bookId}".`);
return textResult(
`Chapter written for "${bookId}". ` +
`Word count: ${(result as any).wordCount ?? "unknown"}.`,
);
}
case "auditor": {
if (!bookId) return textResult("Error: bookId is required for the auditor agent.");
progress(`Auditing draft for "${bookId}"...`);
const audit = await pipeline.auditDraft(bookId);
progress(`Audit complete for "${bookId}".`);
const issueCount = audit.issues?.length ?? 0;
return textResult(
`Audit complete for "${bookId}": ${issueCount} issue(s) found. ` +
`Chapter ${audit.chapterNumber}.`,
);
}
case "reviser": {
if (!bookId) return textResult("Error: bookId is required for the reviser agent.");
// Detect revision mode from instruction keywords
const mode: ReviseMode = /rewrite|改写|重写/.test(instruction)
? "rewrite"
: /polish|润色/.test(instruction)
? "polish"
: /rework|返工/.test(instruction)
? "rework"
: "spot-fix";
progress(`Revising "${bookId}" in ${mode} mode...`);
await pipeline.reviseDraft(bookId, undefined, mode);
progress(`Revision complete for "${bookId}".`);
return textResult(`Revision (${mode}) complete for "${bookId}".`);
}
case "exporter": {
return textResult("Export is not yet implemented. Coming soon.");
}
default:
return textResult(`Unknown agent: ${agent}`);
}
} catch (err: any) {
console.error(`[sub_agent] "${agent}" failed:`, err);
return textResult(`Sub-agent "${agent}" failed: ${err?.message ?? String(err)}`);
}
},
};
}
// ---------------------------------------------------------------------------
// 2. Read Tool
// ---------------------------------------------------------------------------
const ReadParams = Type.Object({
path: Type.String({ description: "File path relative to books/, e.g. {bookId}/story/story_bible.md" }),
});
export function createReadTool(projectRoot: string): AgentTool<typeof ReadParams> {
const booksRoot = join(projectRoot, "books");
return {
name: "read",
description: "Read a file from the book directory. Path is relative to books/.",
label: "Read File",
parameters: ReadParams,
async execute(
_toolCallId: string,
params: Static<typeof ReadParams>,
): Promise<AgentToolResult<undefined>> {
try {
const filePath = safeBooksPath(booksRoot, params.path);
let content = await readFile(filePath, "utf-8");
if (content.length > 10_000) {
content = content.slice(0, 10_000) + "\n\n... [truncated at 10 000 chars]";
}
return textResult(content);
} catch (err: any) {
return textResult(`Failed to read "${params.path}": ${err?.message ?? String(err)}`);
}
},
};
}
// ---------------------------------------------------------------------------
// 3. Edit Tool
// ---------------------------------------------------------------------------
const EditParams = Type.Object({
path: Type.String({ description: "File path relative to books/" }),
old_string: Type.String({ description: "Exact string to find in the file" }),
new_string: Type.String({ description: "Replacement string" }),
});
export function createEditTool(projectRoot: string): AgentTool<typeof EditParams> {
const booksRoot = join(projectRoot, "books");
return {
name: "edit",
description:
"Edit a file using exact string replacement. " +
"old_string must appear exactly once in the file. Path is relative to books/.",
label: "Edit File",
parameters: EditParams,
async execute(
_toolCallId: string,
params: Static<typeof EditParams>,
): Promise<AgentToolResult<undefined>> {
try {
const filePath = safeBooksPath(booksRoot, params.path);
const content = await readFile(filePath, "utf-8");
const idx = content.indexOf(params.old_string);
if (idx === -1) {
return textResult(`old_string not found in "${params.path}".`);
}
if (content.indexOf(params.old_string, idx + 1) !== -1) {
return textResult(`old_string appears more than once in "${params.path}". Provide a more specific match.`);
}
const updated = content.slice(0, idx) + params.new_string + content.slice(idx + params.old_string.length);
await writeFile(filePath, updated, "utf-8");
return textResult(`File "${params.path}" updated successfully.`);
} catch (err: any) {
return textResult(`Failed to edit "${params.path}": ${err?.message ?? String(err)}`);
}
},
};
}
// ---------------------------------------------------------------------------
// 4. Grep Tool
// ---------------------------------------------------------------------------
const GrepParams = Type.Object({
bookId: Type.String({ description: "Book ID to search within" }),
pattern: Type.String({ description: "Search pattern (plain text or regex)" }),
});
export function createGrepTool(projectRoot: string): AgentTool<typeof GrepParams> {
const booksRoot = join(projectRoot, "books");
return {
name: "grep",
description:
"Search for a text pattern across a book's story/ and chapters/ directories. Returns matching lines.",
label: "Search",
parameters: GrepParams,
async execute(
_toolCallId: string,
params: Static<typeof GrepParams>,
): Promise<AgentToolResult<undefined>> {
try {
const bookDir = safeBooksPath(booksRoot, params.bookId);
const regex = new RegExp(params.pattern, "gi");
const results: string[] = [];
async function searchDir(dir: string, prefix: string) {
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return; // directory doesn't exist
}
for (const entry of entries) {
const fullPath = join(dir, entry);
const entryStat = await stat(fullPath);
if (entryStat.isDirectory()) {
await searchDir(fullPath, `${prefix}${entry}/`);
} else if (entry.endsWith(".md") || entry.endsWith(".txt") || entry.endsWith(".json")) {
const content = await readFile(fullPath, "utf-8");
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i])) {
results.push(`${prefix}${entry}:${i + 1}: ${lines[i]}`);
regex.lastIndex = 0; // reset for next test
}
}
}
}
}
await Promise.all([
searchDir(join(bookDir, "story"), "story/"),
searchDir(join(bookDir, "chapters"), "chapters/"),
]);
if (results.length === 0) {
return textResult(`No matches for "${params.pattern}" in book "${params.bookId}".`);
}
const truncated = results.length > 100
? results.slice(0, 100).join("\n") + `\n\n... [${results.length - 100} more matches]`
: results.join("\n");
return textResult(truncated);
} catch (err: any) {
return textResult(`Grep failed: ${err?.message ?? String(err)}`);
}
},
};
}
// ---------------------------------------------------------------------------
// 5. Ls Tool
// ---------------------------------------------------------------------------
const LsParams = Type.Object({
bookId: Type.String({ description: "Book ID" }),
subdir: Type.Optional(
Type.String({ description: "Subdirectory within the book, e.g. 'story', 'chapters', 'story/runtime'" }),
),
});
export function createLsTool(projectRoot: string): AgentTool<typeof LsParams> {
const booksRoot = join(projectRoot, "books");
return {
name: "ls",
description: "List files in a book directory. Optionally specify a subdirectory like 'story' or 'chapters'.",
label: "List Files",
parameters: LsParams,
async execute(
_toolCallId: string,
params: Static<typeof LsParams>,
): Promise<AgentToolResult<undefined>> {
try {
const base = safeBooksPath(booksRoot, params.bookId);
const target = params.subdir ? safeBooksPath(base, params.subdir) : base;
const entries = await readdir(target);
const details: string[] = [];
for (const entry of entries) {
const fullPath = join(target, entry);
try {
const entryStat = await stat(fullPath);
const suffix = entryStat.isDirectory() ? "/" : ` (${entryStat.size} bytes)`;
details.push(`${entry}${suffix}`);
} catch {
details.push(entry);
}
}
if (details.length === 0) {
return textResult(`Directory is empty: ${params.bookId}/${params.subdir ?? ""}`);
}
return textResult(details.join("\n"));
} catch (err: any) {
return textResult(`Failed to list "${params.bookId}/${params.subdir ?? ""}": ${err?.message ?? String(err)}`);
}
},
};
}
+3
View File
@@ -0,0 +1,3 @@
export { buildAgentSystemPrompt } from "./agent-system-prompt.js";
export { createSubAgentTool, createReadTool, createEditTool, createGrepTool, createLsTool } from "./agent-tools.js";
export { runAgentSession, evictAgentCache, type AgentSessionConfig, type AgentSessionResult } from "./agent-session.js";
+5 -5
View File
@@ -279,7 +279,7 @@ ${finalRequirementsPrompt}`;
const response = await this.chat([
{ role: "system", content: langPrefix + systemPrompt },
{ role: "user", content: userMessage },
], { maxTokens: 16384, temperature: 0.8 });
], { temperature: 0.8 });
return this.parseSections(response.content);
}
@@ -332,8 +332,8 @@ ${finalRequirementsPrompt}`;
writeFile(
join(storyDir, "character_matrix.md"),
language === "en"
? "# Character Matrix\n\n### Character Profiles\n| Character | Core Tags | Contrast Detail | Speech Style | Personality Core | Relationship to Protagonist | Core Motivation | Current Goal |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n\n### Encounter Log\n| Character A | Character B | First Meeting Chapter | Latest Interaction Chapter | Relationship Type | Relationship Change |\n| --- | --- | --- | --- | --- | --- |\n\n### Information Boundaries\n| Character | Known Information | Unknown Information | Source Chapter |\n| --- | --- | --- | --- |\n"
: "# 角色交互矩阵\n\n### 角色档案\n| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |\n|------|----------|----------|----------|----------|------------|----------|----------|\n\n### 相遇记录\n| 角色A | 角色B | 首次相遇章 | 最近交互章 | 关系性质 | 关系变化 |\n|-------|-------|------------|------------|----------|----------|\n\n### 信息边界\n| 角色 | 已知信息 | 未知信息 | 信息来源章 |\n|------|----------|----------|------------|\n",
? "# Character Matrix\n\n<!-- One ## section per character. Add new characters as new ## blocks. -->\n"
: "# 角色矩阵\n\n<!-- 每个角色一个 ## 块,新角色追加新 ## 即可。 -->\n",
"utf-8",
),
);
@@ -676,7 +676,7 @@ ${keyPrinciplesPrompt}`;
role: "user",
content: userMessage,
},
], { maxTokens: 16384, temperature: 0.5 });
], { temperature: 0.5 });
return this.parseSections(response.content);
}
@@ -765,7 +765,7 @@ prohibitions:
role: "user",
content: `请为标题为"${book.title}"的${fanficMode}模式同人小说生成基础设定。目标${book.targetChapters}章,每章${book.chapterWordCount}字。`,
},
], { maxTokens: 16384, temperature: 0.7 });
], { temperature: 0.7 });
return this.parseSections(response.content);
}
+32 -4
View File
@@ -169,7 +169,7 @@ export class ChapterAnalyzerAgent extends BaseAgent {
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
{ maxTokens: 16384, temperature: 0.3 },
{ temperature: 0.3 },
);
const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
@@ -292,7 +292,21 @@ Updated subplot board (Markdown table)
Updated emotional arcs (Markdown table)
=== UPDATED_CHARACTER_MATRIX ===
Updated character interaction matrix (Markdown table)
Updated character matrix (one ## section per character, bullet-list fields):
## Character Name
- **Role**: protagonist / antagonist / ally / minor / mentioned
- **Tags**: core identity tags
- **Contrast**: distinctive details that defy expectations
- **Speech**: speaking style summary
- **Personality**: core personality traits
- **Motivation**: fundamental driving force
- **Current**: immediate goal this chapter
- **Relationships**: OtherChar(type/Ch#) | ...
- **Known**: what this character knows (only witnessed or told)
- **Unknown**: what this character does not know
(Repeat for each character. Add new characters; keep existing ones updated.)
## Rules
@@ -385,14 +399,28 @@ ${bookRulesBody ? `## 本书规则\n\n${bookRulesBody}` : ""}
更新后的情感弧线(Markdown表格)
=== UPDATED_CHARACTER_MATRIX ===
更新后的角色交互矩阵(Markdown表格)
更新后的角色矩阵(每个角色一个 ## 块,字段用 bullet list):
## 角色名
- **定位**: 主角 / 反派 / 盟友 / 配角 / 提及
- **标签**: 核心身份标签
- **反差**: 打破刻板印象的独特细节
- **说话**: 说话风格概述
- **性格**: 性格底色
- **动机**: 根本驱动力
- **当前**: 本章即时目标
- **关系**: 某角色(关系性质/Ch#) | ...
- **已知**: 该角色已知的信息(仅限亲历或被告知)
- **未知**: 该角色不知道的信息
(每个角色重复以上格式。新角色追加新 ## 块,已有角色做增量更新。)
## 关键规则
1. 状态卡和伏笔池必须基于"当前追踪文件"做增量更新,不是从零开始
2. 正文中的每一个事实性变化都必须反映在对应的追踪文件中
3. 不要遗漏细节:数值变化、位置变化、关系变化、信息变化都要记录
4. 角色交互矩阵中的"信息边界"要准确——角色只知道他在场时发生的事`;
4. 角色矩阵中的"已知/未知"要准确——角色只知道他在场时发生的事`;
}
private buildUserPrompt(params: {
+1 -1
View File
@@ -96,7 +96,7 @@ export class ConsolidatorAgent extends BaseAgent {
role: "user",
content: `Volume: ${vol.name} (Chapters ${vol.startCh}-${vol.endCh})\n\nChapter summaries:\n${header}\n${volSummaryRows}`,
},
], { temperature: 0.3, maxTokens: 1024 });
], { temperature: 0.3 });
newSummaries.push(`\n## ${vol.name} (Ch.${vol.startCh}-${vol.endCh})\n\n${response.content.trim()}`);
}
+1 -1
View File
@@ -536,7 +536,7 @@ ${chapterContent}`;
{ role: "system" as const, content: systemPrompt },
{ role: "user" as const, content: userPrompt },
];
const chatOptions = { temperature: options?.temperature ?? 0.3, maxTokens: 8192 };
const chatOptions = { temperature: options?.temperature ?? 0.3 };
// Use web search for fact verification when eraResearch is enabled
const response = gp.eraResearch
@@ -94,7 +94,7 @@ ${truncated ? "\n注意:原作素材过长,已截断。请基于已有部分
{ role: "system", content: systemPrompt },
{ role: "user", content: `以下是原作《${sourceName}》的素材:\n\n${text}` },
],
{ maxTokens: 8192, temperature: 0.3 },
{ temperature: 0.3 },
);
const content = response.content;
@@ -47,7 +47,7 @@ export class FoundationReviewerAgent extends BaseAgent {
const response = await this.chat([
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
], { maxTokens: 4096, temperature: 0.3 });
], { temperature: 0.3 });
return this.parseReviewResult(response.content, dimensions);
}
+1 -1
View File
@@ -94,7 +94,7 @@ ${rankingsText}
content: `请基于上面的实时排行榜数据,分析当前网文市场热度,给出开书建议。`,
},
],
{ temperature: 0.6, maxTokens: 4096 },
{ temperature: 0.6 },
);
return this.parseResult(response.content);
+1 -1
View File
@@ -93,7 +93,7 @@ ${chapterContent.slice(0, 6000)}`;
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
{ temperature: 0.1, maxTokens: 2048 },
{ temperature: 0.1 },
);
return this.parseResult(response.content);
+12 -12
View File
@@ -624,17 +624,17 @@ ${updatedLedger}
|------|------|----------|----------|------------|----------|
=== UPDATED_CHARACTER_MATRIX ===
(更新后的角色交互矩阵,分三个子表)
(更新后的角色矩阵,每个角色一个 ## 块)
### 角色档案
| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |
|------|----------|----------|----------|----------|------------|----------|----------|
### 相遇记录
| 角色A | 角色B | 首次相遇章 | 最近交互章 | 关系性质 | 关系变化 |
|-------|-------|------------|------------|----------|----------|
### 信息边界
| 角色 | 已知信息 | 未知信息 | 信息来源章 |
|------|----------|----------|------------|`;
## 角色
- **定位**: 主角 / 反派 / 盟友 / 配角 / 提及
- **标签**: 核心身份标签
- **反差**: 打破刻板印象的独特细节
- **说话**: 说话风格概述
- **性格**: 性格底色
- **动机**: 根本驱动力
- **当前**: 本章即时目标
- **关系**: 某角色(关系性质/Ch#) | ...
- **已知**: 该角色已知信息(仅限亲历或被告知)
- **未知**: 该角色不知道的信息`;
}
+1 -1
View File
@@ -546,7 +546,7 @@ export class WriterAgent extends BaseAgent {
{ role: "system", content: observerSystem },
{ role: "user", content: observerUser },
],
{ maxTokens: 4096, temperature: 0.5 },
{ temperature: 0.5 },
);
const observations = observerResponse.content;
+23
View File
@@ -90,10 +90,12 @@ export {
} from "./interaction/events.js";
export {
BookCreationDraftSchema,
DraftRoundSchema,
PendingDecisionSchema,
InteractionMessageSchema,
InteractionSessionSchema,
type BookCreationDraft,
type DraftRound,
type PendingDecision,
type InteractionMessage,
type InteractionSession,
@@ -104,6 +106,12 @@ export {
updateCreationDraft,
appendInteractionMessage,
appendInteractionEvent,
BookSessionSchema,
GlobalSessionSchema,
type BookSession,
type GlobalSession,
createBookSession,
appendBookSessionMessage,
} from "./interaction/session.js";
export {
resolveProjectSessionPath,
@@ -111,7 +119,10 @@ export {
loadProjectSession,
persistProjectSession,
resolveSessionActiveBook,
loadGlobalSession,
persistGlobalSession,
} from "./interaction/project-session-store.js";
export { loadBookSession, persistBookSession, listBookSessions, findOrCreateBookSession } from "./interaction/book-session-store.js";
export { routeInteractionRequest } from "./interaction/request-router.js";
export {
routeNaturalLanguageIntent,
@@ -140,9 +151,21 @@ export {
type InteractionRuntimeTools,
type InteractionRuntimeResult,
} from "./interaction/runtime.js";
export {
parseDraftDirectives,
createDirectiveStreamFilter,
type ParsedDraftResponse,
} from "./interaction/draft-directive-parser.js";
// Agent (pi-agent integration)
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 { SERVICE_PRESETS, SERVICE_TO_PI_PROVIDER, resolveServicePreset, guessServiceFromBaseUrl, listModelsForService, listServicesWithModelCount, type ServicePreset, type ModelInfo } from "./llm/service-presets.js";
export { resolveServiceModel, type ResolvedModel } from "./llm/service-resolver.js";
export { loadSecrets, saveSecrets, getServiceApiKey, type SecretsFile } from "./llm/secrets.js";
export { migrateConfig, type MigrationResult } from "./llm/config-migration.js";
// Agents
export { BaseAgent, type AgentContext } from "./agents/base.js";
@@ -0,0 +1,78 @@
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { BookSessionSchema, createBookSession } from "./session.js";
import type { BookSession } from "./session.js";
const SESSIONS_DIR = ".inkos/sessions";
function sessionsDir(projectRoot: string): string {
return join(projectRoot, SESSIONS_DIR);
}
function sessionPath(projectRoot: string, sessionId: string): string {
return join(sessionsDir(projectRoot), `${sessionId}.json`);
}
export async function loadBookSession(
projectRoot: string,
sessionId: string,
): Promise<BookSession | null> {
try {
const raw = await readFile(sessionPath(projectRoot, sessionId), "utf-8");
return BookSessionSchema.parse(JSON.parse(raw));
} catch {
return null;
}
}
export async function persistBookSession(
projectRoot: string,
session: BookSession,
): Promise<void> {
const dir = sessionsDir(projectRoot);
await mkdir(dir, { recursive: true });
await writeFile(
sessionPath(projectRoot, session.sessionId),
JSON.stringify(session, null, 2),
);
}
export async function listBookSessions(
projectRoot: string,
bookId: string | null,
): Promise<ReadonlyArray<BookSession>> {
const dir = sessionsDir(projectRoot);
let files: string[];
try {
files = await readdir(dir);
} catch {
return [];
}
const sessions: BookSession[] = [];
for (const file of files) {
if (!file.endsWith(".json")) continue;
try {
const raw = await readFile(join(dir, file), "utf-8");
const session = BookSessionSchema.parse(JSON.parse(raw));
if (session.bookId === bookId) {
sessions.push(session);
}
} catch {
// skip corrupt files
}
}
return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
}
export async function findOrCreateBookSession(
projectRoot: string,
bookId: string | null,
): Promise<BookSession> {
const existing = await listBookSessions(projectRoot, bookId);
if (existing.length > 0) return existing[0];
const session = createBookSession(bookId);
await persistBookSession(projectRoot, session);
return session;
}
@@ -0,0 +1,266 @@
/**
* Draft directive parser — extracts structured form data from LLM output
* that uses markdown directive syntax (:::type{attrs}...:::).
*
* Used by both TUI (textContent) and Studio (raw + fields).
*/
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface ParsedDraftResponse {
/** key → value extracted from directive blocks */
fields: Record<string, string>;
/** Raw text with all ::: directive blocks stripped (for TUI display) */
textContent: string;
/** Auto-generated turn summary, e.g. "确立了书名、世界观和主角" */
summary: string;
/** Original LLM output, untouched */
raw: string;
}
// ---------------------------------------------------------------------------
// Attribute parsing
// ---------------------------------------------------------------------------
interface DirectiveAttrs {
type: string; // "field" | "pick" | "number" | "group"
key?: string;
label?: string;
fieldType?: string; // the `type` attribute on field directives
}
const DIRECTIVE_OPEN_RE = /^:::(field|pick|number|group)\{(.+)\}\s*$/;
const DIRECTIVE_CLOSE_RE = /^:::\s*$/;
const CODE_FENCE_RE = /^(`{3,}|~{3,})/;
const LIST_ITEM_RE = /^-\s+(.+)$/;
function parseAttrs(attrStr: string): Record<string, string> {
const attrs: Record<string, string> = {};
// Match key="value" or key='value'
const re = /(\w+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
let m: RegExpExecArray | null;
while ((m = re.exec(attrStr)) !== null) {
attrs[m[1]!] = m[2] ?? m[3] ?? "";
}
return attrs;
}
function parseDirectiveOpen(line: string): DirectiveAttrs | null {
const m = DIRECTIVE_OPEN_RE.exec(line);
if (!m) return null;
const type = m[1]!;
const rawAttrs = parseAttrs(m[2]!);
return {
type,
key: rawAttrs["key"],
label: rawAttrs["label"],
fieldType: rawAttrs["type"],
};
}
// ---------------------------------------------------------------------------
// State machine for full-text parsing
// ---------------------------------------------------------------------------
type ParserMode = "text" | "directive" | "codeblock";
interface DirectiveFrame {
attrs: DirectiveAttrs;
contentLines: string[];
}
/**
* Parse raw LLM output containing markdown directive blocks.
*
* State machine:
* text → directive (on :::type{...})
* text → codeblock (on ``` or ~~~)
* directive → text (on standalone :::)
* directive → directive (on nested :::type{...} inside group)
* codeblock → text (on matching fence close)
*/
export function parseDraftDirectives(raw: string): ParsedDraftResponse {
const lines = raw.split("\n");
const fields: Record<string, string> = {};
const labels: string[] = [];
const textLines: string[] = [];
let mode: ParserMode = "text";
let codeFenceMarker = "";
// Stack of open directives — supports nesting (group > field/number).
const stack: DirectiveFrame[] = [];
for (const line of lines) {
// --- Code-block handling (highest priority) ---
if (mode === "codeblock") {
textLines.push(line);
if (CODE_FENCE_RE.test(line) && line.trimStart().startsWith(codeFenceMarker)) {
mode = "text";
codeFenceMarker = "";
}
continue;
}
if (mode === "text") {
const fenceMatch = CODE_FENCE_RE.exec(line);
if (fenceMatch) {
codeFenceMarker = fenceMatch[1]!;
mode = "codeblock";
textLines.push(line);
continue;
}
}
// --- Directive close (standalone :::) ---
if (DIRECTIVE_CLOSE_RE.test(line) && stack.length > 0) {
const frame = stack.pop()!;
const { attrs, contentLines } = frame;
if (attrs.type !== "group" && attrs.key) {
const value = extractValue(attrs.type, contentLines);
fields[attrs.key] = value;
if (attrs.label) {
labels.push(attrs.label);
}
}
// If we just closed the last frame, we're back in text mode
if (stack.length === 0) {
mode = "text";
}
continue;
}
// --- Directive open ---
const directiveOpen = parseDirectiveOpen(line);
if (directiveOpen) {
stack.push({ attrs: directiveOpen, contentLines: [] });
mode = "directive";
continue;
}
// --- Inside a directive: collect content ---
if (mode === "directive" && stack.length > 0) {
stack[stack.length - 1]!.contentLines.push(line);
continue;
}
// --- Normal text ---
textLines.push(line);
}
return {
fields,
textContent: textLines.join("\n"),
summary: buildSummary(labels),
raw,
};
}
// ---------------------------------------------------------------------------
// Value extraction per directive type
// ---------------------------------------------------------------------------
function extractValue(type: string, contentLines: string[]): string {
if (type === "pick") {
// Extract first list item value
for (const line of contentLines) {
const m = LIST_ITEM_RE.exec(line.trim());
if (m) return m[1]!.trim();
}
return "";
}
// field, number — join all content lines, trim surrounding whitespace
return contentLines.join("\n").trim();
}
// ---------------------------------------------------------------------------
// Summary builder
// ---------------------------------------------------------------------------
function buildSummary(labels: string[]): string {
if (labels.length === 0) return "";
if (labels.length === 1) return `确立了${labels[0]}`;
if (labels.length === 2) return `确立了${labels[0]}${labels[1]}`;
// 3+: 确立了A、B和C
const allButLast = labels.slice(0, -1).join("、");
return `确立了${allButLast}${labels[labels.length - 1]}`;
}
// ---------------------------------------------------------------------------
// Streaming filter
// ---------------------------------------------------------------------------
/**
* Creates a stateful filter function for streaming LLM output.
* Text portions pass through immediately; directive blocks (:::...:::)
* are buffered and suppressed.
*
* Usage:
* const filter = createDirectiveStreamFilter();
* onChunk(chunk => { const visible = filter(chunk); display(visible); });
*/
export function createDirectiveStreamFilter(): (chunk: string) => string {
let depth = 0; // nesting depth of open directives
let inCodeBlock = false;
let codeFenceMarker = "";
return (chunk: string): string => {
const lines = chunk.split("\n");
const outputParts: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const isLastLine = i === lines.length - 1;
// --- Code-block toggle ---
if (inCodeBlock) {
if (CODE_FENCE_RE.test(line) && line.trimStart().startsWith(codeFenceMarker)) {
inCodeBlock = false;
codeFenceMarker = "";
}
outputParts.push(line);
if (!isLastLine) outputParts.push("\n");
continue;
}
// Detect code-fence opening (only outside directives)
if (depth === 0) {
const fenceMatch = CODE_FENCE_RE.exec(line);
if (fenceMatch) {
inCodeBlock = true;
codeFenceMarker = fenceMatch[1]!;
outputParts.push(line);
if (!isLastLine) outputParts.push("\n");
continue;
}
}
// --- Directive open ---
if (parseDirectiveOpen(line)) {
depth++;
continue;
}
// --- Directive close ---
if (DIRECTIVE_CLOSE_RE.test(line) && depth > 0) {
depth--;
continue;
}
// --- Inside directive: suppress ---
if (depth > 0) {
continue;
}
// --- Normal text: pass through ---
outputParts.push(line);
if (!isLastLine) outputParts.push("\n");
}
return outputParts.join("");
};
}
@@ -1,6 +1,6 @@
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { InteractionSessionSchema, type InteractionSession } from "./session.js";
import { InteractionSessionSchema, type InteractionSession, GlobalSessionSchema, type GlobalSession } from "./session.js";
const SESSION_DIR = ".inkos";
const SESSION_FILE = "session.json";
@@ -36,6 +36,28 @@ export async function persistProjectSession(
await writeFile(resolveProjectSessionPath(projectRoot), JSON.stringify(session, null, 2), "utf-8");
}
export async function loadGlobalSession(projectRoot: string): Promise<GlobalSession> {
try {
const raw = await readFile(join(projectRoot, SESSION_DIR, SESSION_FILE), "utf-8");
const data = JSON.parse(raw);
return GlobalSessionSchema.parse({
activeBookId: data.activeBookId,
automationMode: data.automationMode ?? "semi",
});
} catch {
return { automationMode: "semi" };
}
}
export async function persistGlobalSession(
projectRoot: string,
global: GlobalSession,
): Promise<void> {
const dir = join(projectRoot, SESSION_DIR);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, SESSION_FILE), JSON.stringify(global, null, 2));
}
export async function resolveSessionActiveBook(
projectRoot: string,
session: InteractionSession,
+175 -120
View File
@@ -9,8 +9,9 @@ import type {
LLMClient,
BookConfig,
Platform,
ToolDefinition,
} from "../index.js";
import { chatCompletion } from "../index.js";
import { chatCompletion, chatWithTools } from "../index.js";
import { executeEditTransaction } from "./edit-controller.js";
import type { InteractionRuntimeTools } from "./runtime.js";
import type { BookCreationDraft } from "./session.js";
@@ -45,84 +46,6 @@ function normalizePlatform(platform?: string): Platform {
}
}
function extractBalancedJsonObject(text: string): string | null {
const start = text.indexOf("{");
if (start < 0) {
return null;
}
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < text.length; index += 1) {
const char = text[index]!;
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === "\"") {
inString = false;
}
continue;
}
if (char === "\"") {
inString = true;
continue;
}
if (char === "{") {
depth += 1;
continue;
}
if (char === "}") {
depth -= 1;
if (depth === 0) {
return text.slice(start, index + 1);
}
if (depth < 0) {
return null;
}
}
}
return null;
}
function parseCreationDraftResult(text: string): {
readonly assistantReply: string;
readonly draft: BookCreationDraft;
} | null {
const candidate = extractBalancedJsonObject(text);
if (!candidate) {
return null;
}
try {
const parsed = JSON.parse(candidate) as {
assistantReply?: string;
draft?: BookCreationDraft;
};
if (!parsed.assistantReply || !parsed.draft) {
return null;
}
return {
assistantReply: parsed.assistantReply,
draft: parsed.draft,
};
} catch {
return null;
}
}
function deriveBookId(title: string): string {
return title
.toLowerCase()
@@ -437,11 +360,142 @@ 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: "创意简述,会传给 Architect 智能体生成完整的世界观、主角、冲突等 foundation 文件。把用户提到的所有创意要素都写进这里。" },
},
required: ["title", "genre", "platform", "brief"],
},
};
const BOOK_DRAFT_SYSTEM_PROMPT = [
"你是 InkOS 的建书助手。用户会描述想写的书,你需要调用 create_book 工具来生成建书参数。",
"",
"规则:",
"1. 从用户描述中推断所有字段,大胆预填合理默认值。",
"2. brief 字段要详细——它会传给 Architect 智能体生成完整的世界观、主角、冲突等 foundation 文件。把用户提到的所有创意要素都写进 brief。",
"3. 如果用户后续要求修改某些字段,重新调用 create_book 工具,只更新被提到的字段,其余保持不变。",
"4. 不要只回复文字讨论——必须调用 create_book 工具输出结构化参数。",
].join("\n");
/** Map directive field keys to BookCreationDraft property names. */
function applyFieldsToDraft(
existing: BookCreationDraft | undefined,
fields: Readonly<Record<string, string>>,
concept: string,
): BookCreationDraft {
const draft: BookCreationDraft = {
concept,
missingFields: [],
readyToCreate: false,
...(existing ?? {}),
};
for (const [key, value] of Object.entries(fields)) {
if (!value) continue;
switch (key) {
case "title":
draft.title = value;
break;
case "genre":
draft.genre = value;
break;
case "platform":
draft.platform = value;
break;
case "language":
if (value === "zh" || value === "en") draft.language = value;
break;
case "targetChapters": {
const n = parseInt(value, 10);
if (!Number.isNaN(n) && n > 0) draft.targetChapters = n;
break;
}
case "chapterWordCount":
case "chapterLength": {
const n = parseInt(value, 10);
if (!Number.isNaN(n) && n > 0) draft.chapterWordCount = n;
break;
}
case "blurb":
draft.blurb = value;
break;
case "worldPremise":
draft.worldPremise = value;
break;
case "settingNotes":
draft.settingNotes = value;
break;
case "protagonist":
draft.protagonist = value;
break;
case "supportingCast":
draft.supportingCast = value;
break;
case "conflictCore":
draft.conflictCore = value;
break;
case "volumeOutline":
draft.volumeOutline = value;
break;
case "constraints":
draft.constraints = value;
break;
case "authorIntent":
draft.authorIntent = value;
break;
case "currentFocus":
draft.currentFocus = value;
break;
// Unknown keys are silently ignored — the LLM may emit
// application-level keys we don't map to the draft struct.
}
}
return draft;
}
function formatDraftForUserMessage(
existingDraft: BookCreationDraft | undefined,
userMessage: string,
): string {
const parts: string[] = [];
if (existingDraft) {
parts.push("## 当前草案状态");
const entries = Object.entries(existingDraft).filter(
([, v]) => v !== undefined && v !== "" && !(Array.isArray(v) && v.length === 0),
);
for (const [key, value] of entries) {
parts.push(`- **${key}**: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`);
}
parts.push("");
}
parts.push("## 用户输入");
parts.push(userMessage);
return parts.join("\n");
}
export function createInteractionToolsFromDeps(
pipeline: PipelineLike,
state: StateLike,
hooks?: {
readonly onChatTextDelta?: (text: string) => void;
readonly onDraftTextDelta?: (text: string) => void;
readonly onDraftRawDelta?: (text: string) => void;
readonly getChatRequestOptions?: () => {
readonly temperature?: number;
readonly maxTokens?: number;
@@ -453,70 +507,71 @@ export function createInteractionToolsFromDeps(
return {
listBooks: () => state.listBooks(),
developBookDraft: async (input, existingDraft) => {
const concept = existingDraft?.concept ?? input;
if (!instrumentedPipeline.config?.client || !instrumentedPipeline.config?.model) {
const concept = existingDraft?.concept ?? input;
// Fallback: no LLM configured
return {
__interaction: {
responseText: "先把这本书的大概方向收住。你更想写长篇连载,还是十来章能收住的版本?",
responseText: "请先配置 LLM 模型,然后再创建书籍。",
details: {
creationDraft: {
concept,
title: existingDraft?.title,
genre: existingDraft?.genre,
platform: existingDraft?.platform,
language: existingDraft?.language,
targetChapters: existingDraft?.targetChapters,
chapterWordCount: existingDraft?.chapterWordCount,
blurb: existingDraft?.blurb,
authorIntent: existingDraft?.authorIntent,
currentFocus: existingDraft?.currentFocus,
nextQuestion: "你更想写长篇连载,还是十来章能收住的版本?",
missingFields: existingDraft?.missingFields ?? ["title", "genre", "targetChapters"],
readyToCreate: existingDraft?.readyToCreate ?? false,
} satisfies BookCreationDraft,
missingFields: ["title", "genre", "targetChapters"],
readyToCreate: false,
},
},
},
};
}
const response = await chatCompletion(
// 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: [
"You are InkOS book ideation assistant.",
"Turn the user's latest message and the current draft into a tighter book creation draft.",
"Ask at most one sharp next question.",
"Default to concise Chinese unless the draft language is clearly English.",
"Return JSON only with keys assistantReply and draft.",
"draft must include concept and may include title, genre, platform, language, targetChapters, chapterWordCount, blurb, worldPremise, settingNotes, protagonist, supportingCast, conflictCore, volumeOutline, constraints, authorIntent, currentFocus, nextQuestion, missingFields, readyToCreate.",
"Help the user decide and revise worldview, setting, protagonist, supporting cast, core conflict, blurb, and volume direction.",
"Be conservative: only mark readyToCreate=true when the draft already has a workable title, genre, targetChapters, chapterWordCount, and enough setting/conflict detail to generate a foundation.",
].join(" "),
},
{
role: "user",
content: JSON.stringify({
currentDraft: existingDraft ?? null,
latestMessage: input,
}, null, 2),
},
{ role: "system", content: BOOK_DRAFT_SYSTEM_PROMPT },
{ role: "user", content: userContent },
],
[CREATE_BOOK_TOOL],
{ temperature: 0.4 },
);
const parsed = parseCreationDraftResult(response.content);
if (!parsed) {
throw new Error("Book draft assistant returned invalid JSON.");
// Extract tool call if present
const toolCall = result.toolCalls[0];
let parsedArgs: Record<string, unknown> = {};
if (toolCall) {
try {
parsedArgs = JSON.parse(toolCall.arguments);
} catch {
// If parsing fails, use empty args
}
}
// Build a draft from tool call arguments
const draft: BookCreationDraft = {
concept,
title: (parsedArgs.title as string) ?? existingDraft?.title,
genre: (parsedArgs.genre as string) ?? existingDraft?.genre,
platform: (parsedArgs.platform as string) ?? existingDraft?.platform,
language: (parsedArgs.language as "zh" | "en") ?? existingDraft?.language,
targetChapters: (parsedArgs.targetChapters as number) ?? existingDraft?.targetChapters,
chapterWordCount: (parsedArgs.chapterWordCount as number) ?? existingDraft?.chapterWordCount,
blurb: (parsedArgs.brief as string) ?? existingDraft?.blurb,
missingFields: [],
readyToCreate: Boolean(parsedArgs.title && parsedArgs.genre && parsedArgs.platform),
};
return {
__interaction: {
responseText: parsed.assistantReply,
responseText: result.content || "已生成建书参数,请确认或修改。",
details: {
creationDraft: parsed.draft,
creationDraft: draft,
toolCall: toolCall ? { name: toolCall.name, arguments: parsedArgs } : undefined,
},
},
};
+15 -2
View File
@@ -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 } from "./session.js";
import type { PendingDecision, InteractionSession, DraftRound } from "./session.js";
import {
appendInteractionEvent,
bindActiveBook,
@@ -380,7 +380,20 @@ async function handleDraftLifecycleRequest(params: {
en: "Book-draft tool did not return draft data.",
}));
}
const nextSession = appendToolEvents(updateCreationDraft(session, draft), metadata.events);
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,
+87
View File
@@ -11,9 +11,34 @@ export const PendingDecisionSchema = z.object({
export type PendingDecision = z.infer<typeof PendingDecisionSchema>;
export const PipelineStageSchema = z.object({
label: z.string(),
status: z.enum(["pending", "active", "completed"]),
});
export type PipelineStage = z.infer<typeof PipelineStageSchema>;
export const ToolExecutionSchema = z.object({
id: z.string(),
tool: z.string(),
agent: z.string().optional(),
label: z.string(),
status: z.enum(["running", "processing", "completed", "error"]),
args: z.record(z.unknown()).optional(),
result: z.string().optional(),
error: z.string().optional(),
stages: z.array(PipelineStageSchema).optional(),
startedAt: z.number(),
completedAt: z.number().optional(),
});
export type ToolExecution = z.infer<typeof ToolExecutionSchema>;
export const InteractionMessageSchema = z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string().min(1),
thinking: z.string().optional(),
toolExecutions: z.array(ToolExecutionSchema).optional(),
timestamp: z.number().int().nonnegative(),
});
@@ -44,12 +69,24 @@ export const BookCreationDraftSchema = z.object({
export type BookCreationDraft = z.infer<typeof BookCreationDraftSchema>;
export const DraftRoundSchema = z.object({
roundId: z.number().int().min(1),
userMessage: z.string(),
assistantRaw: z.string(),
fieldsUpdated: z.array(z.string()).default([]),
summary: z.string().default(""),
timestamp: z.number().int().nonnegative(),
});
export type DraftRound = z.infer<typeof DraftRoundSchema>;
export const InteractionSessionSchema = z.object({
sessionId: z.string().min(1),
projectRoot: z.string().min(1),
activeBookId: z.string().min(1).optional(),
activeChapterNumber: z.number().int().min(1).optional(),
creationDraft: BookCreationDraftSchema.optional(),
draftRounds: z.array(DraftRoundSchema).default([]),
automationMode: AutomationModeSchema.default("semi"),
messages: z.array(InteractionMessageSchema).default([]),
events: z.array(InteractionEventSchema).default([]),
@@ -59,6 +96,55 @@ export const InteractionSessionSchema = z.object({
export type InteractionSession = z.infer<typeof InteractionSessionSchema>;
// -- Per-book session --
export const BookSessionSchema = z.object({
sessionId: z.string().min(1),
bookId: z.string().nullable(),
messages: z.array(InteractionMessageSchema).default([]),
creationDraft: BookCreationDraftSchema.optional(),
draftRounds: z.array(DraftRoundSchema).default([]),
events: z.array(InteractionEventSchema).default([]),
currentExecution: ExecutionStateSchema.optional(),
createdAt: z.number().int().nonnegative(),
updatedAt: z.number().int().nonnegative(),
});
export type BookSession = z.infer<typeof BookSessionSchema>;
// -- Global session (simplified) --
export const GlobalSessionSchema = z.object({
activeBookId: z.string().min(1).optional(),
automationMode: AutomationModeSchema.default("semi"),
});
export type GlobalSession = z.infer<typeof GlobalSessionSchema>;
export function createBookSession(bookId: string | null): BookSession {
const now = Date.now();
return {
sessionId: `${now}-${Math.random().toString(36).slice(2, 8)}`,
bookId,
messages: [],
draftRounds: [],
events: [],
createdAt: now,
updatedAt: now,
};
}
export function appendBookSessionMessage(
session: BookSession,
message: InteractionMessage,
): BookSession {
return {
...session,
messages: [...session.messages, message].sort((a, b) => a.timestamp - b.timestamp),
updatedAt: Date.now(),
};
}
export function bindActiveBook(
session: InteractionSession,
bookId: string,
@@ -100,6 +186,7 @@ export function clearCreationDraft(session: InteractionSession): InteractionSess
return {
...session,
creationDraft: undefined,
draftRounds: [],
};
}
+58
View File
@@ -0,0 +1,58 @@
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { saveSecrets, loadSecrets } from "./secrets.js";
import { guessServiceFromBaseUrl } from "./service-presets.js";
export interface MigrationResult {
migrated: boolean;
}
export async function migrateConfig(projectRoot: string): Promise<MigrationResult> {
const configPath = join(projectRoot, "inkos.json");
let raw: string;
try {
raw = await readFile(configPath, "utf-8");
} catch {
return { migrated: false };
}
const config = JSON.parse(raw);
const llm = config.llm;
if (!llm) return { migrated: false };
// Already new format
if (Array.isArray(llm.services)) return { migrated: false };
// Old format: llm.provider, llm.model, llm.baseUrl, llm.apiKey
const { provider, model, baseUrl, apiKey, ...restLlm } = llm;
if (!model && !provider) return { migrated: false };
// Determine service from baseUrl
const guessedService = baseUrl ? guessServiceFromBaseUrl(baseUrl) : null;
const service = guessedService ?? "custom";
// Build new service entry
const serviceEntry: Record<string, string> = { service };
if (service === "custom") {
serviceEntry.name = "Custom";
if (baseUrl) serviceEntry.baseUrl = baseUrl;
}
// Write new config (no apiKey)
config.llm = {
...restLlm,
services: [serviceEntry],
defaultModel: model,
};
await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
// Move apiKey to secrets
if (apiKey) {
const secrets = await loadSecrets(projectRoot);
const secretKey = service === "custom" ? `custom:${serviceEntry.name}` : service;
secrets.services[secretKey] = { apiKey };
await saveSecrets(projectRoot, secrets);
}
return { migrated: true };
}
+176 -659
View File
@@ -1,6 +1,18 @@
import OpenAI from "openai";
import Anthropic from "@anthropic-ai/sdk";
import type { LLMConfig } from "../models/project.js";
import {
streamSimple as piStreamSimple,
stream as piStream,
} 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";
// === Streaming Monitor Types ===
@@ -73,8 +85,8 @@ export interface LLMClient {
readonly provider: "openai" | "anthropic";
readonly apiFormat: "chat" | "responses";
readonly stream: boolean;
readonly _openai?: OpenAI;
readonly _anthropic?: Anthropic;
readonly _piModel?: PiModel<PiApi>;
readonly _apiKey?: string;
readonly defaults: {
readonly temperature: number;
readonly maxTokens: number;
@@ -123,28 +135,34 @@ export function createLLMClient(config: LLMConfig): LLMClient {
const apiFormat = config.apiFormat ?? "chat";
const stream = config.stream ?? true;
if (config.provider === "anthropic") {
// Anthropic SDK appends /v1/ internally — strip if user included it
const baseURL = config.baseUrl.replace(/\/v1\/?$/, "");
return {
provider: "anthropic",
apiFormat,
stream,
_anthropic: new Anthropic({ apiKey: config.apiKey, baseURL }),
defaults,
};
}
// openai or custom — both use OpenAI SDK
// --- Build pi-ai Model object ---
const serviceName = config.service ?? "custom";
const preset = resolveServicePreset(serviceName);
const piApi = (preset?.api ?? "openai-completions") as PiApi;
const baseUrl = config.baseUrl || preset?.baseUrl || "";
const extraHeaders = config.headers ?? parseEnvHeaders();
const piModel: PiModel<PiApi> = {
id: config.model,
name: config.model,
api: piApi,
provider: serviceName,
baseUrl,
reasoning: (config.thinkingBudget ?? 0) > 0,
input: ["text"] as ("text" | "image")[],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: config.maxTokens ?? 8192,
...(extraHeaders ? { headers: extraHeaders } : {}),
};
const provider = config.provider === "anthropic" ? "anthropic" : "openai";
return {
provider: "openai",
provider,
apiFormat,
stream,
_openai: new OpenAI({
apiKey: config.apiKey,
baseURL: config.baseUrl,
...(extraHeaders ? { defaultHeaders: extraHeaders } : {}),
}),
_piModel: piModel,
_apiKey: config.apiKey,
defaults,
};
}
@@ -273,23 +291,6 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
return error instanceof Error ? error : new Error(msg);
}
function wrapStreamRequiredError(
streamError: unknown,
syncError: unknown,
context?: { readonly baseUrl?: string; readonly model?: string },
): Error {
const ctxLine = context
? `\n (baseUrl: ${context.baseUrl}, model: ${context.model})`
: "";
return new Error(
`API 提供方要求使用流式请求(stream:true),不能回退到同步模式。` +
`\n 这次失败不是模型名错误,而是前一次流式请求先失败了,随后同步回退又被提供方拒绝。` +
`\n 建议:保持 stream:true,并检查该提供方/代理的 SSE 流是否稳定。` +
`\n 原始流式错误:${String(streamError)}` +
`\n 同步回退错误:${String(syncError)}${ctxLine}`,
);
}
// === Simple Chat (used by all agents via BaseAgent.chat()) ===
export async function chatCompletion(
@@ -316,22 +317,10 @@ export async function chatCompletion(
};
const onStreamProgress = options?.onStreamProgress;
const onTextDelta = options?.onTextDelta;
const errorCtx = { baseUrl: client._openai?.baseURL ?? "(anthropic)", model };
const errorCtx = { baseUrl: client._piModel?.baseUrl ?? "(unknown)", model };
try {
if (client.provider === "anthropic") {
return client.stream
? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onStreamProgress, onTextDelta)
: await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onTextDelta);
}
if (client.apiFormat === "responses") {
return client.stream
? await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
: await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
}
return client.stream
? await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
: await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
return await chatCompletionViaPiAi(client, model, messages, resolved, onStreamProgress, onTextDelta);
} catch (error) {
// Stream interrupted but partial content is usable — return truncated response
if (error instanceof PartialResponseError) {
@@ -340,59 +329,10 @@ export async function chatCompletion(
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
};
}
// Auto-fallback: if streaming failed, retry with sync (many proxies don't support SSE)
if (client.stream) {
const isStreamRelated = isLikelyStreamError(error);
if (isStreamRelated) {
try {
if (client.provider === "anthropic") {
return await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget);
}
if (client.apiFormat === "responses") {
return await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch);
}
return await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch);
} catch (syncError) {
if (isStreamRequiredError(syncError)) {
throw wrapStreamRequiredError(error, syncError, errorCtx);
}
throw wrapLLMError(syncError, errorCtx);
}
}
}
throw wrapLLMError(error, errorCtx);
}
}
function isLikelyStreamError(error: unknown): boolean {
const msg = String(error).toLowerCase();
// Common indicators that streaming specifically is the problem:
// - SSE parse errors, chunked transfer issues, content-type mismatches
// - Some proxies return 400/415 when stream=true
// - "stream" mentioned in error, or generic network errors during streaming
return (
msg.includes("stream") ||
msg.includes("text/event-stream") ||
msg.includes("chunked") ||
msg.includes("unexpected end") ||
msg.includes("premature close") ||
msg.includes("terminated") ||
msg.includes("econnreset") ||
(msg.includes("400") && !msg.includes("content"))
);
}
function isStreamRequiredError(error: unknown): boolean {
const msg = String(error).toLowerCase();
return (
msg.includes("stream must be set to true") ||
(msg.includes("stream") && msg.includes("must be set to true")) ||
(msg.includes("stream") && msg.includes("required"))
);
}
// === Tool-calling Chat (used by agent loop) ===
export async function chatWithTools(
@@ -413,259 +353,158 @@ export async function chatWithTools(
),
maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
};
// Tool-calling always uses streaming (only used by agent loop, not by writer/auditor)
if (client.provider === "anthropic") {
return await chatWithToolsAnthropic(client._anthropic!, model, messages, tools, resolved, client.defaults.thinkingBudget);
}
if (client.apiFormat === "responses") {
return await chatWithToolsOpenAIResponses(client._openai!, model, messages, tools, resolved);
}
return await chatWithToolsOpenAIChat(client._openai!, model, messages, tools, resolved);
return await chatWithToolsViaPiAi(client, model, messages, tools, resolved);
} catch (error) {
throw wrapLLMError(error);
}
}
// === OpenAI Chat Completions API Implementation (default) ===
// === pi-ai Unified Implementation ===
async function chatCompletionOpenAIChat(
client: OpenAI,
model: string,
messages: ReadonlyArray<LLMMessage>,
options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
webSearch?: boolean,
onStreamProgress?: OnStreamProgress,
onTextDelta?: (text: string) => void,
): Promise<LLMResponse> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createParams: any = {
model,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
temperature: options.temperature,
max_tokens: options.maxTokens,
stream: true,
...(webSearch ? { web_search_options: { search_context_size: "medium" as const } } : {}),
...stripReservedKeys(options.extra),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stream = await client.chat.completions.create(createParams) as any;
const chunks: string[] = [];
let inputTokens = 0;
let outputTokens = 0;
const monitor = createStreamMonitor(onStreamProgress);
try {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (delta) {
chunks.push(delta);
monitor.onChunk(delta);
onTextDelta?.(delta);
}
if (chunk.usage) {
inputTokens = chunk.usage.prompt_tokens ?? 0;
outputTokens = chunk.usage.completion_tokens ?? 0;
}
}
} catch (streamError) {
monitor.stop();
const partial = chunks.join("");
if (partial.length >= MIN_SALVAGEABLE_CHARS) {
throw new PartialResponseError(partial, streamError);
}
throw streamError;
} finally {
monitor.stop();
}
const content = chunks.join("");
if (!content) throw new Error("LLM returned empty response from stream");
return {
content,
usage: {
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens,
},
};
/**
* Build a pi-ai Model<Api> for a specific per-call model name.
* The base template comes from client._piModel (created in createLLMClient);
* we override .id / .name when the caller passes a different model string
* (e.g. agent overrides).
*/
function resolvePiModel(client: LLMClient, model: string): PiModel<PiApi> {
const base = client._piModel!;
if (base.id === model) return base;
return { ...base, id: model, name: model };
}
async function chatCompletionOpenAIChatSync(
client: OpenAI,
model: string,
messages: ReadonlyArray<LLMMessage>,
options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
_webSearch?: boolean,
onTextDelta?: (text: string) => void,
): Promise<LLMResponse> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const syncParams: any = {
model,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
temperature: options.temperature,
max_tokens: options.maxTokens,
stream: false,
...stripReservedKeys(options.extra),
};
const response = await client.chat.completions.create(syncParams);
const content = response.choices[0]?.message?.content ?? "";
if (!content) throw new Error("LLM returned empty response");
onTextDelta?.(content);
return {
content,
usage: {
promptTokens: response.usage?.prompt_tokens ?? 0,
completionTokens: response.usage?.completion_tokens ?? 0,
totalTokens: response.usage?.total_tokens ?? 0,
},
};
}
async function chatWithToolsOpenAIChat(
client: OpenAI,
model: string,
messages: ReadonlyArray<AgentMessage>,
tools: ReadonlyArray<ToolDefinition>,
options: { readonly temperature: number; readonly maxTokens: number },
): Promise<ChatWithToolsResult> {
const openaiMessages = agentMessagesToOpenAIChat(messages);
const openaiTools: OpenAI.Chat.Completions.ChatCompletionTool[] = tools.map((t) => ({
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: t.parameters,
},
}));
const stream = await client.chat.completions.create({
model,
messages: openaiMessages,
tools: openaiTools,
temperature: options.temperature,
max_tokens: options.maxTokens,
stream: true,
});
let content = "";
const toolCallMap = new Map<number, { id: string; name: string; arguments: string }>();
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (delta?.content) content += delta.content;
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
const existing = toolCallMap.get(tc.index);
if (existing) {
existing.arguments += tc.function?.arguments ?? "";
} else {
toolCallMap.set(tc.index, {
id: tc.id ?? "",
name: tc.function?.name ?? "",
arguments: tc.function?.arguments ?? "",
});
}
/** Convert inkos LLMMessage[] to pi-ai Context. */
function toPiContext(messages: ReadonlyArray<LLMMessage>): PiContext {
const systemParts = messages.filter((m) => m.role === "system").map((m) => m.content);
const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
const piMessages = messages
.filter((m) => m.role !== "system")
.map((m) => {
if (m.role === "user") {
return { role: "user" as const, content: m.content, timestamp: Date.now() };
}
}
}
const toolCalls: ToolCall[] = [...toolCallMap.values()];
return { content, toolCalls };
// assistant
return {
role: "assistant" as const,
content: [{ type: "text" as const, text: m.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" as const,
timestamp: Date.now(),
};
});
return { systemPrompt, messages: piMessages };
}
function agentMessagesToOpenAIChat(
messages: ReadonlyArray<AgentMessage>,
): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
const result: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
/** 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") {
result.push({ role: "system", content: msg.content });
continue;
}
if (msg.role === "system") continue;
if (msg.role === "user") {
result.push({ role: "user", content: msg.content });
piMessages.push({ role: "user", content: msg.content, timestamp: Date.now() });
continue;
}
if (msg.role === "assistant") {
const assistantMsg: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
role: "assistant",
content: msg.content ?? null,
};
if (msg.toolCalls && msg.toolCalls.length > 0) {
assistantMsg.tool_calls = msg.toolCalls.map((tc) => ({
id: tc.id,
type: "function" as const,
function: { name: tc.name, arguments: tc.arguments },
}));
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),
});
}
}
result.push(assistantMsg);
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") {
result.push({
role: "tool",
tool_call_id: msg.toolCallId,
content: msg.content,
piMessages.push({
role: "toolResult",
toolCallId: msg.toolCallId,
toolName: "",
content: [{ type: "text", text: msg.content }],
isError: false,
timestamp: Date.now(),
});
}
}
return result;
return { systemPrompt, messages: piMessages };
}
// === OpenAI Responses API Implementation (optional) ===
/** 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 chatCompletionOpenAIResponses(
client: OpenAI,
async function chatCompletionViaPiAi(
client: LLMClient,
model: string,
messages: ReadonlyArray<LLMMessage>,
options: { readonly temperature: number; readonly maxTokens: number },
webSearch?: boolean,
resolved: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
onStreamProgress?: OnStreamProgress,
onTextDelta?: (text: string) => void,
): Promise<LLMResponse> {
const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
role: m.role as "system" | "user" | "assistant",
content: m.content,
}));
const tools: OpenAI.Responses.Tool[] | undefined = webSearch
? [{ type: "web_search_preview" as const }]
: undefined;
const stream = await client.responses.create({
model,
input,
temperature: options.temperature,
max_output_tokens: options.maxTokens,
stream: true,
...(tools ? { tools } : {}),
});
const piModel = resolvePiModel(client, model);
const context = toPiContext(messages);
const streamOpts = {
temperature: resolved.temperature,
maxTokens: resolved.maxTokens,
apiKey: client._apiKey,
headers: piModel.headers,
};
const eventStream = piStreamSimple(piModel, context, streamOpts);
const chunks: string[] = [];
const monitor = createStreamMonitor(onStreamProgress);
let inputTokens = 0;
let outputTokens = 0;
const monitor = createStreamMonitor(onStreamProgress);
try {
for await (const event of stream) {
if (event.type === "response.output_text.delta") {
for await (const event of eventStream) {
if (event.type === "text_delta") {
chunks.push(event.delta);
monitor.onChunk(event.delta);
onTextDelta?.(event.delta);
}
if (event.type === "response.completed") {
inputTokens = event.response.usage?.input_tokens ?? 0;
outputTokens = event.response.usage?.output_tokens ?? 0;
if (event.type === "done" || event.type === "error") {
const msg = event.type === "done" ? event.message : event.error;
inputTokens = msg.usage.input;
outputTokens = msg.usage.output;
if (event.type === "error" && msg.errorMessage) {
// Check if we have partial content worth salvaging
const partial = chunks.join("");
if (partial.length >= MIN_SALVAGEABLE_CHARS) {
throw new PartialResponseError(partial, new Error(msg.errorMessage));
}
throw new Error(msg.errorMessage);
}
}
}
} catch (streamError) {
monitor.stop();
if (streamError instanceof PartialResponseError) throw streamError;
const partial = chunks.join("");
if (partial.length >= MIN_SALVAGEABLE_CHARS) {
throw new PartialResponseError(partial, streamError);
@@ -676,7 +515,11 @@ async function chatCompletionOpenAIResponses(
}
const content = chunks.join("");
if (!content) throw new Error("LLM returned empty response from stream");
if (!content) {
const diag = `usage=${inputTokens}+${outputTokens}`;
console.warn(`[inkos] LLM 流式响应无文本内容 (${diag})`);
throw new Error(`LLM returned empty response from stream (${diag})`);
}
return {
content,
@@ -688,368 +531,42 @@ async function chatCompletionOpenAIResponses(
};
}
async function chatCompletionOpenAIResponsesSync(
client: OpenAI,
model: string,
messages: ReadonlyArray<LLMMessage>,
options: { readonly temperature: number; readonly maxTokens: number },
_webSearch?: boolean,
onTextDelta?: (text: string) => void,
): Promise<LLMResponse> {
const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
role: m.role as "system" | "user" | "assistant",
content: m.content,
}));
const response = await client.responses.create({
model,
input,
temperature: options.temperature,
max_output_tokens: options.maxTokens,
stream: false,
});
const content = response.output
.filter((item): item is OpenAI.Responses.ResponseOutputMessage => item.type === "message")
.flatMap((item) => item.content)
.filter((block): block is OpenAI.Responses.ResponseOutputText => block.type === "output_text")
.map((block) => block.text)
.join("");
if (!content) throw new Error("LLM returned empty response");
onTextDelta?.(content);
return {
content,
usage: {
promptTokens: response.usage?.input_tokens ?? 0,
completionTokens: response.usage?.output_tokens ?? 0,
totalTokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
},
};
}
async function chatWithToolsOpenAIResponses(
client: OpenAI,
async function chatWithToolsViaPiAi(
client: LLMClient,
model: string,
messages: ReadonlyArray<AgentMessage>,
tools: ReadonlyArray<ToolDefinition>,
options: { readonly temperature: number; readonly maxTokens: number },
resolved: { readonly temperature: number; readonly maxTokens: number },
): Promise<ChatWithToolsResult> {
const input = agentMessagesToResponsesInput(messages);
const responsesTools: OpenAI.Responses.Tool[] = tools.map((t) => ({
type: "function" as const,
name: t.name,
description: t.description,
parameters: t.parameters as OpenAI.Responses.FunctionTool["parameters"],
strict: false,
}));
const stream = await client.responses.create({
model,
input,
tools: responsesTools,
temperature: options.temperature,
max_output_tokens: options.maxTokens,
stream: true,
});
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: piModel.headers,
};
const eventStream = piStream(piModel, context, streamOpts);
let content = "";
const toolCalls: ToolCall[] = [];
for await (const event of stream) {
if (event.type === "response.output_text.delta") {
for await (const event of eventStream) {
if (event.type === "text_delta") {
content += event.delta;
}
if (event.type === "response.output_item.done" && event.item.type === "function_call") {
if (event.type === "toolcall_end") {
toolCalls.push({
id: event.item.call_id,
name: event.item.name,
arguments: event.item.arguments,
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 };
}
function agentMessagesToResponsesInput(
messages: ReadonlyArray<AgentMessage>,
): OpenAI.Responses.ResponseInputItem[] {
const result: OpenAI.Responses.ResponseInputItem[] = [];
for (const msg of messages) {
if (msg.role === "system") {
result.push({ role: "system", content: msg.content });
continue;
}
if (msg.role === "user") {
result.push({ role: "user", content: msg.content });
continue;
}
if (msg.role === "assistant") {
if (msg.content) {
result.push({ role: "assistant", content: msg.content });
}
if (msg.toolCalls) {
for (const tc of msg.toolCalls) {
result.push({
type: "function_call" as const,
call_id: tc.id,
name: tc.name,
arguments: tc.arguments,
});
}
}
continue;
}
if (msg.role === "tool") {
result.push({
type: "function_call_output" as const,
call_id: msg.toolCallId,
output: msg.content,
});
}
}
return result;
}
// === Anthropic Implementation ===
async function chatCompletionAnthropic(
client: Anthropic,
model: string,
messages: ReadonlyArray<LLMMessage>,
options: { readonly temperature: number; readonly maxTokens: number },
thinkingBudget: number = 0,
onStreamProgress?: OnStreamProgress,
onTextDelta?: (text: string) => void,
): Promise<LLMResponse> {
const systemText = messages
.filter((m) => m.role === "system")
.map((m) => m.content)
.join("\n\n");
const nonSystem = messages.filter((m) => m.role !== "system");
const stream = await client.messages.create({
model,
...(systemText ? { system: systemText } : {}),
messages: nonSystem.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
...(thinkingBudget > 0
? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
: { temperature: options.temperature }),
max_tokens: options.maxTokens,
stream: true,
});
const chunks: string[] = [];
let inputTokens = 0;
let outputTokens = 0;
const monitor = createStreamMonitor(onStreamProgress);
try {
for await (const event of stream) {
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
chunks.push(event.delta.text);
monitor.onChunk(event.delta.text);
onTextDelta?.(event.delta.text);
}
if (event.type === "message_start") {
inputTokens = event.message.usage?.input_tokens ?? 0;
}
if (event.type === "message_delta") {
outputTokens = ((event as unknown as { usage?: { output_tokens?: number } }).usage?.output_tokens) ?? 0;
}
}
} catch (streamError) {
monitor.stop();
const partial = chunks.join("");
if (partial.length >= MIN_SALVAGEABLE_CHARS) {
throw new PartialResponseError(partial, streamError);
}
throw streamError;
} finally {
monitor.stop();
}
const content = chunks.join("");
if (!content) throw new Error("LLM returned empty response from stream");
return {
content,
usage: {
promptTokens: inputTokens,
completionTokens: outputTokens,
totalTokens: inputTokens + outputTokens,
},
};
}
async function chatCompletionAnthropicSync(
client: Anthropic,
model: string,
messages: ReadonlyArray<LLMMessage>,
options: { readonly temperature: number; readonly maxTokens: number },
thinkingBudget: number = 0,
onTextDelta?: (text: string) => void,
): Promise<LLMResponse> {
const systemText = messages
.filter((m) => m.role === "system")
.map((m) => m.content)
.join("\n\n");
const nonSystem = messages.filter((m) => m.role !== "system");
const response = await client.messages.create({
model,
...(systemText ? { system: systemText } : {}),
messages: nonSystem.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
...(thinkingBudget > 0
? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
: { temperature: options.temperature }),
max_tokens: options.maxTokens,
});
const content = response.content
.filter((block): block is Anthropic.Messages.TextBlock => block.type === "text")
.map((block) => block.text)
.join("");
if (!content) throw new Error("LLM returned empty response");
onTextDelta?.(content);
return {
content,
usage: {
promptTokens: response.usage?.input_tokens ?? 0,
completionTokens: response.usage?.output_tokens ?? 0,
totalTokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
},
};
}
async function chatWithToolsAnthropic(
client: Anthropic,
model: string,
messages: ReadonlyArray<AgentMessage>,
tools: ReadonlyArray<ToolDefinition>,
options: { readonly temperature: number; readonly maxTokens: number },
thinkingBudget: number = 0,
): Promise<ChatWithToolsResult> {
const systemText = messages
.filter((m) => m.role === "system")
.map((m) => (m as { content: string }).content)
.join("\n\n");
const nonSystem = messages.filter((m) => m.role !== "system");
const anthropicMessages = agentMessagesToAnthropic(nonSystem);
const anthropicTools = tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.parameters as Anthropic.Messages.Tool.InputSchema,
}));
const stream = await client.messages.create({
model,
...(systemText ? { system: systemText } : {}),
messages: anthropicMessages,
tools: anthropicTools,
...(thinkingBudget > 0
? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
: { temperature: options.temperature }),
max_tokens: options.maxTokens,
stream: true,
});
let content = "";
const toolCalls: ToolCall[] = [];
let currentBlock: { id: string; name: string; input: string } | null = null;
for await (const event of stream) {
if (event.type === "content_block_start" && event.content_block.type === "tool_use") {
currentBlock = {
id: event.content_block.id,
name: event.content_block.name,
input: "",
};
}
if (event.type === "content_block_delta") {
if (event.delta.type === "text_delta") {
content += event.delta.text;
}
if (event.delta.type === "input_json_delta" && currentBlock) {
currentBlock.input += event.delta.partial_json;
}
}
if (event.type === "content_block_stop" && currentBlock) {
toolCalls.push({
id: currentBlock.id,
name: currentBlock.name,
arguments: currentBlock.input,
});
currentBlock = null;
}
}
return { content, toolCalls };
}
function agentMessagesToAnthropic(
messages: ReadonlyArray<AgentMessage>,
): Anthropic.Messages.MessageParam[] {
const result: Anthropic.Messages.MessageParam[] = [];
for (const msg of messages) {
if (msg.role === "system") continue;
if (msg.role === "user") {
result.push({ role: "user", content: msg.content });
continue;
}
if (msg.role === "assistant") {
const blocks: Anthropic.Messages.ContentBlockParam[] = [];
if (msg.content) {
blocks.push({ type: "text", text: msg.content });
}
if (msg.toolCalls) {
for (const tc of msg.toolCalls) {
blocks.push({
type: "tool_use",
id: tc.id,
name: tc.name,
input: JSON.parse(tc.arguments),
});
}
}
if (blocks.length === 0) {
blocks.push({ type: "text", text: "" });
}
result.push({ role: "assistant", content: blocks });
continue;
}
if (msg.role === "tool") {
const toolResult: Anthropic.Messages.ToolResultBlockParam = {
type: "tool_result",
tool_use_id: msg.toolCallId,
content: msg.content,
};
// Merge consecutive tool results into one user message (Anthropic requires alternating roles)
const prev = result[result.length - 1];
if (prev && prev.role === "user" && Array.isArray(prev.content)) {
(prev.content as Anthropic.Messages.ToolResultBlockParam[]).push(toolResult);
} else {
result.push({ role: "user", content: [toolResult] });
}
}
}
return result;
}
+50
View File
@@ -0,0 +1,50 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
export interface SecretsFile {
services: Record<string, { apiKey: string }>;
}
const SECRETS_DIR = ".inkos";
const SECRETS_FILE = "secrets.json";
export async function loadSecrets(projectRoot: string): Promise<SecretsFile> {
try {
const raw = await readFile(
join(projectRoot, SECRETS_DIR, SECRETS_FILE),
"utf-8",
);
return JSON.parse(raw) as SecretsFile;
} catch {
return { services: {} };
}
}
export async function saveSecrets(
projectRoot: string,
secrets: SecretsFile,
): Promise<void> {
const dir = join(projectRoot, SECRETS_DIR);
await mkdir(dir, { recursive: true });
await writeFile(
join(dir, SECRETS_FILE),
JSON.stringify(secrets, null, 2),
"utf-8",
);
}
export async function getServiceApiKey(
projectRoot: string,
service: string,
): Promise<string | null> {
// 1. secrets.json
const secrets = await loadSecrets(projectRoot);
const entry = secrets.services[service];
if (entry?.apiKey) return entry.apiKey;
// 2. Environment variable: MOONSHOT_API_KEY, DEEPSEEK_API_KEY, etc.
const envKey = `${service.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase()}_API_KEY`;
if (process.env[envKey]) return process.env[envKey]!;
return null;
}
+143
View File
@@ -0,0 +1,143 @@
export interface ServicePreset {
readonly api: string;
readonly baseUrl: string;
readonly label: string;
readonly temperatureRange?: [number, number];
readonly defaultTemperature?: number;
readonly writingTemperature?: number;
readonly temperatureHint?: string;
}
export const SERVICE_PRESETS: Record<string, ServicePreset> = {
openai: { api: "openai-responses", baseUrl: "https://api.openai.com/v1", label: "OpenAI", temperatureRange: [0, 2], defaultTemperature: 1.0, writingTemperature: 1.0 },
anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com", label: "Anthropic", temperatureRange: [0, 1], defaultTemperature: 1.0, writingTemperature: 1.0, temperatureHint: "不要同时改 temperature 和 top_p" },
deepseek: { api: "openai-completions", baseUrl: "https://api.deepseek.com", label: "DeepSeek", temperatureRange: [0, 2], defaultTemperature: 1.0, writingTemperature: 1.5, temperatureHint: "创意写作推荐 1.5" },
moonshot: { api: "openai-completions", baseUrl: "https://api.moonshot.cn/v1", label: "Moonshot (Kimi)", temperatureRange: [0, 1], defaultTemperature: 0.3, writingTemperature: 1.0, temperatureHint: "kimi-k2.5 推荐 temperature=1.0" },
minimax: { api: "openai-completions", baseUrl: "https://api.minimax.chat/v1", label: "MiniMax", temperatureRange: [0, 2], defaultTemperature: 0.9, writingTemperature: 0.9 },
bailian: { api: "openai-completions", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", label: "百炼 (通义千问)", temperatureRange: [0, 2], defaultTemperature: 0.7, writingTemperature: 1.0 },
zhipu: { api: "openai-completions", baseUrl: "https://open.bigmodel.cn/api/paas/v4", label: "智谱 GLM", temperatureRange: [0, 1], defaultTemperature: 0.95, writingTemperature: 0.95 },
siliconflow: { api: "openai-completions", baseUrl: "https://api.siliconflow.cn/v1", label: "硅基流动" },
ppio: { api: "openai-completions", baseUrl: "https://api.ppinfra.com/v3/openai", label: "PPIO" },
openrouter: { api: "openai-responses", baseUrl: "https://openrouter.ai/api/v1", label: "OpenRouter" },
ollama: { api: "openai-completions", baseUrl: "http://localhost:11434/v1", label: "Ollama (本地)" },
custom: { api: "openai-completions", baseUrl: "", label: "自定义端点" },
};
export function resolveServicePreset(service: string): ServicePreset | undefined {
return SERVICE_PRESETS[service];
}
const DEFAULT_TEMPERATURE_RANGE: [number, number] = [0, 2];
export function clampTemperature(service: string, temperature: number): number {
const preset = resolveServicePreset(service);
const [min, max] = preset?.temperatureRange ?? DEFAULT_TEMPERATURE_RANGE;
return Math.max(min, Math.min(max, temperature));
}
export function getWritingTemperature(service: string): number {
const preset = resolveServicePreset(service);
return preset?.writingTemperature ?? preset?.defaultTemperature ?? 1.0;
}
export function guessServiceFromBaseUrl(baseUrl: string): string {
for (const [key, preset] of Object.entries(SERVICE_PRESETS)) {
if (key === "custom" || !preset.baseUrl) continue;
try {
if (baseUrl.includes(new URL(preset.baseUrl).hostname)) return key;
} catch {
continue;
}
}
return "custom";
}
// pi-ai service → pi-ai provider 映射
export const SERVICE_TO_PI_PROVIDER: Record<string, string> = {
openai: "openai",
anthropic: "anthropic",
deepseek: "openai", // OpenAI 兼容,pi-ai 无独立 provider
moonshot: "openai", // Moonshot API (api.moonshot.cn) 是 OpenAI 兼容,不是 kimi-coding (api.kimi.com)
minimax: "minimax",
bailian: "openai", // 百炼走 OpenAI 兼容
zhipu: "zai", // pi-ai 有 zai provider
siliconflow: "openai", // OpenAI 兼容
ppio: "openai", // OpenAI 兼容
openrouter: "openrouter",
ollama: "openai", // OpenAI 兼容
};
export interface ModelInfo {
readonly id: string;
readonly name: string;
readonly reasoning: boolean;
readonly contextWindow: number;
}
/**
* 动态获取某个 service 下可用的模型列表。
* 优先调用服务商的 GET /models APIOpenAI 兼容),回退到 pi-ai 内置模型列表。
*
* @param apiKey 用户配置的 API key,用于认证 /models 请求
*/
export async function listModelsForService(service: string, apiKey?: string): Promise<ReadonlyArray<ModelInfo>> {
const preset = SERVICE_PRESETS[service];
if (!preset || service === "custom") return [];
// 1) 尝试动态获取:调用 GET {baseUrl}/models
if (apiKey && preset.baseUrl) {
try {
const modelsUrl = preset.baseUrl.replace(/\/$/, "") + "/models";
const res = await fetch(modelsUrl, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(10_000),
});
if (res.ok) {
const json = await res.json() as { data?: Array<{ id: string; owned_by?: string }> };
if (json.data && json.data.length > 0) {
return json.data.map((m) => ({
id: m.id,
name: m.id,
reasoning: false,
contextWindow: 0,
}));
}
}
} catch {
// /models 不可用,回退
}
}
// 2) 回退到 pi-ai 内置模型列表
const piProvider = SERVICE_TO_PI_PROVIDER[service];
if (!piProvider) return [];
try {
const { getModels } = await import("@mariozechner/pi-ai");
const models = getModels(piProvider as any);
return models.map((m: any) => ({
id: m.id,
name: m.name,
reasoning: m.reasoning ?? false,
contextWindow: m.contextWindow ?? 0,
}));
} catch {
return [];
}
}
/**
* 获取所有 service 及其可用模型数。
*/
export async function listServicesWithModelCount(): Promise<ReadonlyArray<{ service: string; label: string; modelCount: number }>> {
const result: { service: string; label: string; modelCount: number }[] = [];
for (const [key, preset] of Object.entries(SERVICE_PRESETS)) {
if (key === "custom") {
result.push({ service: key, label: preset.label, modelCount: 0 });
continue;
}
const models = await listModelsForService(key);
result.push({ service: key, label: preset.label, modelCount: models.length });
}
return result;
}
+66
View File
@@ -0,0 +1,66 @@
import { getModel } from "@mariozechner/pi-ai";
import type { Model, Api } from "@mariozechner/pi-ai";
import { resolveServicePreset, SERVICE_TO_PI_PROVIDER } from "./service-presets.js";
import { getServiceApiKey } from "./secrets.js";
export interface ResolvedModel {
model: Model<Api>;
apiKey: string;
writingTemperature?: number;
temperatureRange?: [number, number];
temperatureHint?: string;
}
export async function resolveServiceModel(
service: string,
modelId: string,
projectRoot: string,
customBaseUrl?: string,
): Promise<ResolvedModel> {
// Resolve API key
const apiKey = await getServiceApiKey(projectRoot, service);
if (!apiKey) {
throw new Error(
`API key not found for service "${service}". Add it in .inkos/secrets.json or set the environment variable.`,
);
}
// Determine pi-ai provider
const baseService = service.startsWith("custom:") ? "custom" : service;
const preset = resolveServicePreset(baseService);
const piProvider = SERVICE_TO_PI_PROVIDER[baseService] ?? "openai";
// Get pi-ai Model — may return undefined for model IDs not in the built-in registry
let model = getModel(piProvider as any, modelId as any) as Model<Api> | undefined;
if (!model) {
// Construct a Model object from service preset for models not in pi-ai's registry
const apiType = preset?.api ?? "openai-completions";
const baseUrl = customBaseUrl ?? preset?.baseUrl ?? "";
if (!baseUrl) {
throw new Error(
`Cannot resolve model "${modelId}" for service "${service}": no baseUrl available.`,
);
}
model = {
id: modelId,
name: modelId,
api: apiType as Api,
provider: piProvider,
baseUrl,
reasoning: false,
input: ["text"] as ("text" | "image")[],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 0,
maxTokens: 16384,
};
}
return {
model,
apiKey,
writingTemperature: preset?.writingTemperature,
temperatureRange: preset?.temperatureRange,
temperatureHint: preset?.temperatureHint,
};
}
+1
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
export const LLMConfigSchema = z.object({
provider: z.enum(["anthropic", "openai", "custom"]),
service: z.string().default("custom"),
baseUrl: z.string().url(),
apiKey: z.string().default(""),
model: z.string().min(1),
@@ -57,7 +57,11 @@ export async function persistChapterArtifacts(params: {
lengthTelemetry: params.lengthTelemetry,
tokenUsage: params.tokenUsage,
};
await params.saveChapterIndex([...existingIndex, entry]);
const existingIdx = existingIndex.findIndex((e) => e.number === params.chapterNumber);
const updatedIndex = existingIdx >= 0
? existingIndex.map((e, i) => i === existingIdx ? { ...entry, createdAt: e.createdAt } : e)
: [...existingIndex, entry];
await params.saveChapterIndex(updatedIndex);
await params.markBookActiveIfNeeded();
const driftIssues = params.auditResult.issues.filter(
@@ -57,7 +57,33 @@ export async function validateChapterTruthPersistence(params: {
params.language,
);
} catch (error) {
throw new Error(`State validation failed for chapter ${params.chapterNumber}: ${String(error)}`);
params.logger?.warn(`State validation error for chapter ${params.chapterNumber}: ${String(error)}`);
const errorDescription = params.language === "en"
? `State validation unavailable: ${String(error)}`
: `状态校验不可用:${String(error)}`;
const errorIssue: AuditIssue = {
severity: "warning",
category: "state-validation",
description: errorDescription,
suggestion: params.language === "en"
? "Repair chapter state from the persisted body before continuing."
: "请先基于已保存正文修复本章 state,再继续后续章节。",
};
return {
validation: { passed: true, warnings: [] },
chapterStatus: "state-degraded",
degradedIssues: [errorIssue],
persistenceOutput: buildStateDegradedPersistenceOutput({
output: persistenceOutput,
oldState: params.previousTruth.oldState,
oldHooks: params.previousTruth.oldHooks,
oldLedger: params.previousTruth.oldLedger,
}),
auditResult: {
...params.auditResult,
issues: [...params.auditResult.issues, errorIssue],
},
};
}
if (validation.warnings.length > 0) {
+8 -3
View File
@@ -393,6 +393,7 @@ export class PipelineRunner {
: base?.apiKey ?? "";
client = createLLMClient({
provider,
service: base?.service ?? "custom",
baseUrl: override.baseUrl,
apiKey,
model: override.model,
@@ -693,7 +694,11 @@ export class PipelineRunner {
lengthTelemetry,
...(draftOutput.tokenUsage ? { tokenUsage: draftOutput.tokenUsage } : {}),
};
await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
const existingIdx = existingIndex.findIndex((e) => e.number === chapterNumber);
const updatedIndex = existingIdx >= 0
? existingIndex.map((e, i) => i === existingIdx ? newEntry : e)
: [...existingIndex, newEntry];
await this.state.saveChapterIndex(bookId, updatedIndex);
await this.markBookActiveIfNeeded(bookId);
// Snapshot
@@ -1808,7 +1813,7 @@ export class PipelineRunner {
role: "user",
content: `分析以下参考文本的写作风格:\n\n${referenceText.slice(0, 20000)}`,
},
], { temperature: 0.3, maxTokens: 4096 });
], { temperature: 0.3 });
await writeFile(join(storyDir, "style_guide.md"), response.content, "utf-8");
return response.content;
@@ -1930,7 +1935,7 @@ ${emotions}
## 正传角色矩阵
${matrix}`,
},
], { temperature: 0.3, maxTokens: 16384 });
], { temperature: 0.3 });
// Append deterministic meta block (LLM may hallucinate timestamps)
const metaBlock = [
+9 -1
View File
@@ -5,6 +5,9 @@ import type { ChapterMeta } from "../models/chapter.js";
import { bootstrapStructuredStateFromMarkdown, resolveDurableStoryProgress } from "./state-bootstrap.js";
export class StateManager {
/** Books actively being written by this process — used for same-process stale lock detection. */
private readonly activeWrites = new Set<string>();
constructor(private readonly projectRoot: string) {}
private static defaultAuthorIntent(language: "zh" | "en"): string {
@@ -93,7 +96,10 @@ export class StateManager {
if (code === "EEXIST") {
const lockData = await readFile(lockPath, "utf-8").catch(() => "pid:unknown ts:unknown");
const lockPid = this.extractLockPid(lockData);
if (lockPid !== undefined && !this.isProcessAlive(lockPid)) {
const isStale =
(lockPid !== undefined && !this.isProcessAlive(lockPid)) ||
(lockPid === process.pid && !this.activeWrites.has(bookId));
if (isStale) {
await unlink(lockPath).catch(() => undefined);
return this.acquireBookLock(bookId);
}
@@ -104,7 +110,9 @@ export class StateManager {
}
throw e;
}
this.activeWrites.add(bookId);
return async () => {
this.activeWrites.delete(bookId);
try {
await unlink(lockPath);
} catch {
+1 -1
View File
@@ -83,7 +83,7 @@ export async function waitForStudioBookReady(
const retryDelayMs = options.retryDelayMs ?? 150;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const response = await fetchImpl(`/api/books/${encodeURIComponent(bookId)}`);
const response = await fetchImpl(`/api/v1/books/${encodeURIComponent(bookId)}`);
if (response.ok) {
return await response.json() as StudioBookDetail;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = process.argv[2] ?? process.env.INKOS_PROJECT_ROOT ?? process.cwd();
const root = resolve(process.argv[2] ?? process.env.INKOS_PROJECT_ROOT ?? process.cwd());
const port = parseInt(process.env.INKOS_STUDIO_PORT ?? "4567", 10);
// Find studio package root (2 levels up from src/api/)
+77 -47
View File
@@ -22,6 +22,12 @@ const processProjectInteractionRequestMock = vi.fn();
const createInteractionToolsFromDepsMock = vi.fn(() => ({}));
const loadProjectSessionMock = vi.fn();
const resolveSessionActiveBookMock = vi.fn();
const runAgentSessionMock = vi.fn();
const findOrCreateBookSessionMock = vi.fn();
const loadBookSessionMock = vi.fn();
const persistBookSessionMock = vi.fn();
const appendBookSessionMessageMock = vi.fn();
const resolveServiceModelMock = vi.fn();
const logger = {
child: () => logger,
@@ -112,6 +118,12 @@ vi.mock("@actalk/inkos-core", () => {
createInteractionToolsFromDeps: createInteractionToolsFromDepsMock,
loadProjectSession: loadProjectSessionMock,
resolveSessionActiveBook: resolveSessionActiveBookMock,
runAgentSession: runAgentSessionMock,
findOrCreateBookSession: findOrCreateBookSessionMock,
loadBookSession: loadBookSessionMock,
persistBookSession: persistBookSessionMock,
appendBookSessionMessage: appendBookSessionMessageMock,
resolveServiceModel: resolveServiceModelMock,
GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
};
});
@@ -262,6 +274,30 @@ describe("createStudioServer daemon lifecycle", () => {
saveChapterIndexMock.mockResolvedValue(undefined);
rollbackToChapterMock.mockResolvedValue([]);
pipelineConfigs.length = 0;
runAgentSessionMock.mockReset();
findOrCreateBookSessionMock.mockReset();
loadBookSessionMock.mockReset();
persistBookSessionMock.mockReset();
appendBookSessionMessageMock.mockReset();
resolveServiceModelMock.mockReset();
// Default BookSession for agent tests
const defaultBookSession = {
sessionId: "agent-session-1",
projectRoot: root,
activeBookId: "demo-book",
messages: [],
events: [],
};
findOrCreateBookSessionMock.mockResolvedValue(defaultBookSession);
loadBookSessionMock.mockResolvedValue(null);
persistBookSessionMock.mockResolvedValue(undefined);
appendBookSessionMessageMock.mockImplementation(
(session: unknown, _msg: unknown) => session,
);
runAgentSessionMock.mockResolvedValue({
responseText: "Agent response.",
messages: [],
});
});
afterEach(async () => {
@@ -281,7 +317,7 @@ describe("createStudioServer daemon lifecycle", () => {
const app = createStudioServer(cloneProjectConfig() as never, root);
const responseOrTimeout = await Promise.race([
app.request("http://localhost/api/daemon/start", { method: "POST" }),
app.request("http://localhost/api/v1/daemon/start", { method: "POST" }),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 30)),
]);
@@ -291,7 +327,7 @@ describe("createStudioServer daemon lifecycle", () => {
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ ok: true, running: true });
const status = await app.request("http://localhost/api/daemon");
const status = await app.request("http://localhost/api/v1/daemon");
await expect(status.json()).resolves.toEqual({ running: true });
resolveStart?.();
@@ -301,7 +337,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/books/..%2Fetc%2Fpasswd", {
const response = await app.request("http://localhost/api/v1/books/..%2Fetc%2Fpasswd", {
method: "GET",
});
@@ -326,14 +362,14 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const readAuthorIntent = await app.request("http://localhost/api/books/demo-book/truth/author_intent.md");
const readAuthorIntent = await app.request("http://localhost/api/v1/books/demo-book/truth/author_intent.md");
expect(readAuthorIntent.status).toBe(200);
await expect(readAuthorIntent.json()).resolves.toMatchObject({
file: "author_intent.md",
content: "# Author Intent\n\nStay cold.\n",
});
const updateCurrentFocus = await app.request("http://localhost/api/books/demo-book/truth/current_focus.md", {
const updateCurrentFocus = await app.request("http://localhost/api/v1/books/demo-book/truth/current_focus.md", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "# Current Focus\n\nPull focus back to the harbor trail.\n" }),
@@ -349,7 +385,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const save = await app.request("http://localhost/api/project", {
const save = await app.request("http://localhost/api/v1/project", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -362,7 +398,7 @@ describe("createStudioServer daemon lifecycle", () => {
expect(save.status).toBe(200);
const project = await app.request("http://localhost/api/project");
const project = await app.request("http://localhost/api/v1/project");
await expect(project.json()).resolves.toMatchObject({
language: "en",
temperature: 0.2,
@@ -394,7 +430,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(startupConfig as never, root);
const response = await app.request("http://localhost/api/doctor");
const response = await app.request("http://localhost/api/v1/doctor");
expect(response.status).toBe(200);
expect(createLLMClientMock).toHaveBeenCalledWith(expect.objectContaining({
@@ -432,7 +468,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(startupConfig as never, root);
const response = await app.request("http://localhost/api/radar/scan", {
const response = await app.request("http://localhost/api/v1/radar/scan", {
method: "POST",
});
@@ -451,7 +487,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const save = await app.request("http://localhost/api/project/language", {
const save = await app.request("http://localhost/api/v1/project/language", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ language: "en" }),
@@ -459,7 +495,7 @@ describe("createStudioServer daemon lifecycle", () => {
expect(save.status).toBe(200);
const project = await app.request("http://localhost/api/project");
const project = await app.request("http://localhost/api/v1/project");
await expect(project.json()).resolves.toMatchObject({
language: "en",
languageExplicit: true,
@@ -474,7 +510,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/books/create", {
const response = await app.request("http://localhost/api/v1/books/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -499,7 +535,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/books/create", {
const response = await app.request("http://localhost/api/v1/books/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -513,7 +549,7 @@ describe("createStudioServer daemon lifecycle", () => {
expect(response.status).toBe(200);
await Promise.resolve();
const status = await app.request("http://localhost/api/books/broken-book/create-status");
const status = await app.request("http://localhost/api/v1/books/broken-book/create-status");
expect(status.status).toBe(200);
await expect(status.json()).resolves.toMatchObject({
status: "error",
@@ -549,7 +585,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/books/demo-book/chapters/3/reject", {
const response = await app.request("http://localhost/api/v1/books/demo-book/chapters/3/reject", {
method: "POST",
});
@@ -569,7 +605,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/books/create", {
const response = await app.request("http://localhost/api/v1/books/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -602,7 +638,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/books/demo-book/revise/3", {
const response = await app.request("http://localhost/api/v1/books/demo-book/revise/3", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "rewrite", brief: "把注意力拉回师债主线。" }),
@@ -617,7 +653,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/books/demo-book/resync/3", {
const response = await app.request("http://localhost/api/v1/books/demo-book/resync/3", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brief: "以师债线为准同步状态。" }),
@@ -632,7 +668,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/books/demo-book/export-save", {
const response = await app.request("http://localhost/api/v1/books/demo-book/export-save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ format: "md", approvedOnly: true }),
@@ -655,26 +691,19 @@ describe("createStudioServer daemon lifecycle", () => {
});
});
it("routes /api/agent through the shared interaction control layer", async () => {
processProjectInteractionInputMock.mockResolvedValue({
request: { intent: "write_next", bookId: "demo-book" },
it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
runAgentSessionMock.mockResolvedValueOnce({
responseText: "Completed write_next for demo-book.",
session: {
sessionId: "session-1",
projectRoot: root,
activeBookId: "demo-book",
automationMode: "semi",
messages: [
{ role: "user", content: "continue", timestamp: 1 },
{ role: "assistant", content: "Completed write_next for demo-book.", timestamp: 2 },
],
},
messages: [
{ role: "user", content: "continue" },
{ role: "assistant", content: "Completed write_next for demo-book." },
],
});
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/agent", {
const response = await app.request("http://localhost/api/v1/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
@@ -683,26 +712,27 @@ describe("createStudioServer daemon lifecycle", () => {
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
response: "Completed write_next for demo-book.",
request: { intent: "write_next", bookId: "demo-book" },
session: expect.objectContaining({
activeBookId: "demo-book",
sessionId: "agent-session-1",
}),
});
expect(createInteractionToolsFromDepsMock).toHaveBeenCalledTimes(1);
expect(processProjectInteractionInputMock).toHaveBeenCalledWith(expect.objectContaining({
projectRoot: root,
input: "continue",
activeBookId: "demo-book",
}));
expect(runAgentSessionMock).toHaveBeenCalledWith(
expect.objectContaining({
bookId: "demo-book",
projectRoot: root,
}),
"continue",
expect.any(Array),
);
});
it("returns 500 with an error payload when the shared agent execution fails", async () => {
processProjectInteractionInputMock.mockRejectedValueOnce(new Error("boom"));
it("returns 500 with an error payload when the agent session fails", async () => {
runAgentSessionMock.mockRejectedValueOnce(new Error("boom"));
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/agent", {
const response = await app.request("http://localhost/api/v1/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instruction: "continue", activeBookId: "demo-book" }),
@@ -711,7 +741,7 @@ describe("createStudioServer daemon lifecycle", () => {
expect(response.status).toBe(500);
await expect(response.json()).resolves.toEqual({
error: {
code: "INTERACTION_ERROR",
code: "AGENT_ERROR",
message: "boom",
},
});
@@ -732,7 +762,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/interaction/session");
const response = await app.request("http://localhost/api/v1/interaction/session");
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
@@ -763,7 +793,7 @@ describe("createStudioServer daemon lifecycle", () => {
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/interaction/session");
const response = await app.request("http://localhost/api/v1/interaction/session");
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
File diff suppressed because it is too large Load Diff