mirror of
https://github.com/Narcooo/inkos.git
synced 2026-08-30 17:22:02 +08:00
feat(core): split backend changes from #182
This commit is contained in:
@@ -13,6 +13,7 @@ function createSession(): InteractionSession {
|
||||
automationMode: "semi",
|
||||
messages: [],
|
||||
events: [],
|
||||
draftRounds: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ function createSession(): InteractionSession {
|
||||
detail: "Preparing chapter 12.",
|
||||
},
|
||||
],
|
||||
draftRounds: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
export const SLASH_COMMANDS = [
|
||||
"/new <idea>",
|
||||
"/new 输入你的想法",
|
||||
"/draft",
|
||||
"/create",
|
||||
"/discard",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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**`;
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ ${rankingsText}
|
||||
content: `请基于上面的实时排行榜数据,分析当前网文市场热度,给出开书建议。`,
|
||||
},
|
||||
],
|
||||
{ temperature: 0.6, maxTokens: 4096 },
|
||||
{ temperature: 0.6 },
|
||||
);
|
||||
|
||||
return this.parseResult(response.content);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -624,17 +624,17 @@ ${updatedLedger}
|
||||
|------|------|----------|----------|------------|----------|
|
||||
|
||||
=== UPDATED_CHARACTER_MATRIX ===
|
||||
(更新后的角色交互矩阵,分三个子表)
|
||||
(更新后的角色矩阵,每个角色一个 ## 块)
|
||||
|
||||
### 角色档案
|
||||
| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |
|
||||
|------|----------|----------|----------|----------|------------|----------|----------|
|
||||
|
||||
### 相遇记录
|
||||
| 角色A | 角色B | 首次相遇章 | 最近交互章 | 关系性质 | 关系变化 |
|
||||
|-------|-------|------------|------------|----------|----------|
|
||||
|
||||
### 信息边界
|
||||
| 角色 | 已知信息 | 未知信息 | 信息来源章 |
|
||||
|------|----------|----------|------------|`;
|
||||
## 角色名
|
||||
- **定位**: 主角 / 反派 / 盟友 / 配角 / 提及
|
||||
- **标签**: 核心身份标签
|
||||
- **反差**: 打破刻板印象的独特细节
|
||||
- **说话**: 说话风格概述
|
||||
- **性格**: 性格底色
|
||||
- **动机**: 根本驱动力
|
||||
- **当前**: 本章即时目标
|
||||
- **关系**: 某角色(关系性质/Ch#) | ...
|
||||
- **已知**: 该角色已知的信息(仅限亲历或被告知)
|
||||
- **未知**: 该角色不知道的信息`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 API(OpenAI 兼容),回退到 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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user