Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ books/
inkos.json
prompt/
CLAUDE.md
my-novel/
package-lock.json
7 changes: 7 additions & 0 deletions .trae/rules/git-commit-message.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
alwaysApply: true
scene: git_message
---

在此处编写规则,自定义 AI 生成提交信息的风格。
简单明了,以流畅的中文描述提交的内容。
307 changes: 225 additions & 82 deletions README.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,9 @@
"@mariozechner/pi-ai": "0.67.1",
"@mariozechner/pi-agent-core": "0.67.1"
}
},
"dependencies": {
"docx": "^9.6.1",
"typescript": "^5.9.3"
}
}
129 changes: 129 additions & 0 deletions packages/core/src/__tests__/model-failover-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
ModelFailoverManager,
createFailoverManager,
isQuotaError,
createDefaultFailoverConfig,
} from "../llm/model-failover-manager.js";

describe("isQuotaError", () => {
it("should detect 429 errors", () => {
expect(isQuotaError(new Error("API 返回 429 (请求过多)"))).toBe(true);
});

it("should detect quota exceeded errors", () => {
expect(isQuotaError(new Error("You exceeded your current quota"))).toBe(true);
expect(isQuotaError(new Error("insufficient_quota"))).toBe(true);
});

it("should detect rate limit errors", () => {
expect(isQuotaError(new Error("Rate limit exceeded"))).toBe(true);
expect(isQuotaError(new Error("rate_limit_exceeded"))).toBe(true);
});

it("should detect Chinese quota errors", () => {
expect(isQuotaError(new Error("额度已用完"))).toBe(true);
expect(isQuotaError(new Error("超出配额"))).toBe(true);
expect(isQuotaError(new Error("余额不足"))).toBe(true);
});

it("should not detect other errors", () => {
expect(isQuotaError(new Error("API 返回 400"))).toBe(false);
expect(isQuotaError(new Error("Connection error"))).toBe(false);
expect(isQuotaError(new Error("Some random error"))).toBe(false);
});
});

describe("ModelFailoverManager", () => {
let manager: ModelFailoverManager;

const config = {
enabled: true,
mode: "auto" as const,
fallbacks: [
{ service: "moonshot", model: "kimi-k2.5" },
{ service: "deepseek", model: "deepseek-chat" },
],
maxAutoSwitches: 3,
retryDelayMs: 100,
};

beforeEach(() => {
manager = createFailoverManager(config, "openai", "gpt-4");
});

it("should be created with initial state", () => {
const state = manager.getState();
expect(state.currentService).toBe("openai");
expect(state.currentModel).toBe("gpt-4");
expect(state.switchedCount).toBe(0);
});

it("should detect quota errors", () => {
const quotaError = new Error("You exceeded your current quota");
expect(manager.isQuotaErrorForCurrentService(quotaError)).toBe(true);
});

it("should not allow auto switch when disabled", () => {
const disabledManager = createFailoverManager(
{ ...config, enabled: false },
"openai",
"gpt-4",
);
expect(disabledManager.canAutoSwitch()).toBe(false);
});

it("should not allow auto switch in manual mode", () => {
const manualManager = createFailoverManager(
{ ...config, mode: "manual" },
"openai",
"gpt-4",
);
expect(manualManager.canAutoSwitch()).toBe(false);
});

it("should require manual switch in manual mode", () => {
const manualManager = createFailoverManager(
{ ...config, mode: "manual" },
"openai",
"gpt-4",
);
const quotaError = new Error("You exceeded your current quota");
expect(manualManager.requiresManualSwitch(quotaError)).toBe(true);
});

it("should allow auto switch when enabled and in auto mode", () => {
expect(manager.canAutoSwitch()).toBe(true);
});

it("should record errors", () => {
const quotaError = new Error("You exceeded your current quota");
manager.recordError("openai", "gpt-4", quotaError);
expect(manager.isQuotaErrorForCurrentService(quotaError)).toBe(true);
});

it("should create SSE event for failover", () => {
const result = {
switched: true,
newService: "moonshot",
newModel: "kimi-k2.5",
fallbackIndex: 0,
reason: "API quota exceeded",
};
const event = manager.createSSEEvent(result, "openai", "gpt-4");
expect(event.type).toBe("model:failover");
expect(event.previousService).toBe("openai");
expect(event.newService).toBe("moonshot");
});
});

describe("createDefaultFailoverConfig", () => {
it("should return disabled config by default", () => {
const config = createDefaultFailoverConfig();
expect(config.enabled).toBe(false);
expect(config.mode).toBe("manual");
expect(config.fallbacks).toEqual([]);
expect(config.maxAutoSwitches).toBe(3);
expect(config.retryDelayMs).toBe(5000);
});
});
7 changes: 7 additions & 0 deletions packages/core/src/__tests__/pipeline-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,13 @@ describe("PipelineRunner", () => {
thinkingBudget: 0,
apiFormat: "chat",
stream: false,
failover: {
enabled: false,
mode: "manual",
fallbacks: [],
maxAutoSwitches: 3,
retryDelayMs: 5000,
},
},
modelOverrides: {
writer: {
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/agent/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface AgentSessionConfig {
allowSystemFileRead?: boolean;
/** Optional listener for streaming events (for SSE forwarding). */
onEvent?: (event: AgentEvent) => void;
/** Optional custom system prompt override (from agentPrompts config). */
customSystemPrompt?: string | null;
}

export interface AgentSessionResult {
Expand All @@ -76,6 +78,7 @@ interface CachedAgent {
modelIdentity: string;
apiKey: string | undefined;
allowSystemFileRead: boolean;
customSystemPrompt: string | null | undefined;
lastCommittedSeq: number;
lastActive: number;
}
Expand Down Expand Up @@ -542,6 +545,7 @@ async function runAgentSessionUnlocked(
const apiKeyChanged = cached.apiKey !== config.apiKey;
const readPermissionChanged = cached.allowSystemFileRead !== allowSystemFileRead;
const transcriptChanged = cached.lastCommittedSeq !== currentCommittedSeq;
const customPromptChanged = cached.customSystemPrompt !== config.customSystemPrompt;

if (
modelChanged ||
Expand All @@ -550,7 +554,8 @@ async function runAgentSessionUnlocked(
languageChanged ||
apiKeyChanged ||
readPermissionChanged ||
transcriptChanged
transcriptChanged ||
customPromptChanged
) {
agentCache.delete(cacheKey);
cached = undefined;
Expand All @@ -570,7 +575,7 @@ async function runAgentSessionUnlocked(
const agent = new Agent({
initialState: {
model,
systemPrompt: buildAgentSystemPrompt(bookId, language),
systemPrompt: config.customSystemPrompt || buildAgentSystemPrompt(bookId, language),
tools: createAgentToolsForMode({ pipeline, bookId, projectRoot, allowSystemFileRead }),
messages: initialAgentMessages,
},
Expand All @@ -592,6 +597,7 @@ async function runAgentSessionUnlocked(
modelIdentity: requestedModelIdentity,
apiKey: config.apiKey,
allowSystemFileRead,
customSystemPrompt: config.customSystemPrompt,
lastCommittedSeq: currentCommittedSeq ?? await latestCommittedSeq(projectRoot, sessionId),
lastActive: Date.now(),
};
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/agent/agent-system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
- agent="writer" **续写下一章**(接着已写的最后一章往下写,无法指定章节号。参数:chapterWordCount)
- agent="auditor" 审计**已有章节**(参数:chapterNumber 指定第几章,不传则审最新一章)
- agent="reviser" 修改**已有章节**(**必须传 chapterNumber 指明改第几章**。参数:chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect)
- agent="exporter" 导出书籍(参数:format: txt/md/epub, approvedOnly: true/false)
- agent="exporter" 导出书籍(参数:format: txt/md/epub/docx, approvedOnly: true/false)
- **writer vs reviser 选择规则**(极易出错,看清楚):
- 用户说"改/修订/重写第 N 章"、"第 N 章 xxx 写得不好" → **reviser** + chapterNumber=N(绝不能用 writer,writer 会写新的第 N+1 章)
- 用户说"写下一章"、"继续写"、"再来一章" → **writer**(不要用 reviser,更不要不带 chapterNumber 调 reviser)
Expand Down Expand Up @@ -155,7 +155,7 @@ export function buildAgentSystemPrompt(bookId: string | null, language: string):
- agent="writer" **continue writing the NEXT chapter** (always appends after the latest written chapter; cannot target a specific number. params: chapterWordCount)
- agent="auditor" audit an **EXISTING chapter** (params: chapterNumber to target a specific chapter; omit for the latest)
- agent="reviser" modify an **EXISTING chapter** (**chapterNumber is required to identify which chapter**. params: chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect)
- agent="exporter" export book (params: format: txt/md/epub, approvedOnly: true/false)
- agent="exporter" export book (params: format: txt/md/epub/docx, approvedOnly: true/false)
- **writer vs reviser — common mistake, read carefully**:
- User says "revise/rewrite/fix chapter N" or "chapter N has issues" → **reviser** with chapterNumber=N (never writer — writer would produce a new chapter N+1)
- User says "write the next chapter" / "continue" / "one more chapter" → **writer** (never reviser, and never call reviser without chapterNumber)
Expand Down
Loading