@ai-zen/air 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-factory.js +1 -1
- package/dist/agent-factory.js.map +1 -1
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +241 -228
- package/dist/agent-runtime.js.map +1 -1
- package/dist/chat/commands/back.d.ts +3 -0
- package/dist/chat/commands/back.d.ts.map +1 -0
- package/dist/chat/commands/back.js +95 -0
- package/dist/chat/commands/back.js.map +1 -0
- package/dist/chat/commands/editor.d.ts +3 -0
- package/dist/chat/commands/editor.d.ts.map +1 -0
- package/dist/chat/commands/editor.js +20 -0
- package/dist/chat/commands/editor.js.map +1 -0
- package/dist/chat/commands/exit.d.ts +3 -0
- package/dist/chat/commands/exit.d.ts.map +1 -0
- package/dist/chat/commands/exit.js +5 -0
- package/dist/chat/commands/exit.js.map +1 -0
- package/dist/chat/commands/help.d.ts +2 -0
- package/dist/chat/commands/help.d.ts.map +1 -0
- package/dist/chat/commands/help.js +4 -0
- package/dist/chat/commands/help.js.map +1 -0
- package/dist/chat/commands/load.d.ts +3 -0
- package/dist/chat/commands/load.d.ts.map +1 -0
- package/dist/chat/commands/load.js +39 -0
- package/dist/chat/commands/load.js.map +1 -0
- package/dist/chat/commands/message.d.ts +5 -0
- package/dist/chat/commands/message.d.ts.map +1 -0
- package/dist/chat/commands/message.js +53 -0
- package/dist/chat/commands/message.js.map +1 -0
- package/dist/chat/commands/new.d.ts +3 -0
- package/dist/chat/commands/new.d.ts.map +1 -0
- package/dist/chat/commands/new.js +6 -0
- package/dist/chat/commands/new.js.map +1 -0
- package/dist/chat/commands/save.d.ts +3 -0
- package/dist/chat/commands/save.d.ts.map +1 -0
- package/dist/chat/commands/save.js +5 -0
- package/dist/chat/commands/save.js.map +1 -0
- package/dist/chat/message.d.ts +3 -0
- package/dist/chat/message.d.ts.map +1 -0
- package/dist/chat/message.js +31 -0
- package/dist/chat/message.js.map +1 -0
- package/dist/chat/print.d.ts +3 -0
- package/dist/chat/print.d.ts.map +1 -0
- package/dist/chat/print.js +24 -0
- package/dist/chat/print.js.map +1 -0
- package/dist/chat/runtime.d.ts +2 -0
- package/dist/chat/runtime.d.ts.map +1 -0
- package/dist/chat/runtime.js +47 -0
- package/dist/chat/runtime.js.map +1 -0
- package/dist/chat/session.d.ts +8 -0
- package/dist/chat/session.d.ts.map +1 -0
- package/dist/chat/session.js +77 -0
- package/dist/chat/session.js.map +1 -0
- package/dist/cli.js +3 -3
- package/dist/cli.js.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/session/commands/back.d.ts +3 -0
- package/dist/session/commands/back.d.ts.map +1 -0
- package/dist/session/commands/back.js +95 -0
- package/dist/session/commands/back.js.map +1 -0
- package/dist/session/commands/editor.d.ts +3 -0
- package/dist/session/commands/editor.d.ts.map +1 -0
- package/dist/session/commands/editor.js +20 -0
- package/dist/session/commands/editor.js.map +1 -0
- package/dist/session/commands/exit.d.ts +3 -0
- package/dist/session/commands/exit.d.ts.map +1 -0
- package/dist/session/commands/exit.js +5 -0
- package/dist/session/commands/exit.js.map +1 -0
- package/dist/session/commands/help.d.ts +2 -0
- package/dist/session/commands/help.d.ts.map +1 -0
- package/dist/session/commands/help.js +4 -0
- package/dist/session/commands/help.js.map +1 -0
- package/dist/session/commands/load.d.ts +3 -0
- package/dist/session/commands/load.d.ts.map +1 -0
- package/dist/session/commands/load.js +37 -0
- package/dist/session/commands/load.js.map +1 -0
- package/dist/session/commands/new.d.ts +3 -0
- package/dist/session/commands/new.d.ts.map +1 -0
- package/dist/session/commands/new.js +6 -0
- package/dist/session/commands/new.js.map +1 -0
- package/dist/session/commands/save.d.ts +3 -0
- package/dist/session/commands/save.d.ts.map +1 -0
- package/dist/session/commands/save.js +5 -0
- package/dist/session/commands/save.js.map +1 -0
- package/dist/session/message.d.ts +3 -0
- package/dist/session/message.d.ts.map +1 -0
- package/dist/session/message.js +31 -0
- package/dist/session/message.js.map +1 -0
- package/dist/session/print.d.ts +3 -0
- package/dist/session/print.d.ts.map +1 -0
- package/dist/session/print.js +24 -0
- package/dist/session/print.js.map +1 -0
- package/dist/session/runtime.d.ts +2 -0
- package/dist/session/runtime.d.ts.map +1 -0
- package/dist/session/runtime.js +46 -0
- package/dist/session/runtime.js.map +1 -0
- package/dist/session/shared.d.ts +8 -0
- package/dist/session/shared.d.ts.map +1 -0
- package/dist/session/shared.js +77 -0
- package/dist/session/shared.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/e2e.test.ts +187 -0
- package/src/__tests__/session.test.ts +147 -0
- package/src/agent-factory.ts +1 -1
- package/src/cli.ts +4 -4
- package/src/config.ts +1 -1
- package/src/session/commands/back.ts +90 -0
- package/src/session/commands/editor.ts +18 -0
- package/src/session/commands/exit.ts +6 -0
- package/src/session/commands/help.ts +3 -0
- package/src/session/commands/load.ts +38 -0
- package/src/session/commands/new.ts +7 -0
- package/src/session/commands/save.ts +6 -0
- package/src/session/message.ts +27 -0
- package/src/session/print.ts +27 -0
- package/src/session/runtime.ts +47 -0
- package/src/session/shared.ts +83 -0
- package/src/agent-runtime.ts +0 -265
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync, rmSync, mkdirSync, readdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
|
|
7
|
+
// ==================== Helpers ====================
|
|
8
|
+
|
|
9
|
+
const CLI = join(process.cwd(), "dist", "cli.js");
|
|
10
|
+
|
|
11
|
+
interface CliResult {
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
exitCode: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function runCli(args: string[], stdinInput?: string, extraEnv?: Record<string, string>): Promise<CliResult> {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const env = {
|
|
20
|
+
...process.env,
|
|
21
|
+
...extraEnv,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const proc = spawn("node", [CLI, ...args], { env, stdio: ["pipe", "pipe", "pipe"] });
|
|
25
|
+
|
|
26
|
+
let stdout = "";
|
|
27
|
+
let stderr = "";
|
|
28
|
+
|
|
29
|
+
proc.stdout.on("data", (data: Buffer) => { stdout += data.toString(); });
|
|
30
|
+
proc.stderr.on("data", (data: Buffer) => { stderr += data.toString(); });
|
|
31
|
+
|
|
32
|
+
if (stdinInput !== undefined) {
|
|
33
|
+
proc.stdin.write(stdinInput);
|
|
34
|
+
proc.stdin.end();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
proc.on("close", (code) => {
|
|
38
|
+
resolve({ stdout, stderr, exitCode: code ?? -1 });
|
|
39
|
+
});
|
|
40
|
+
proc.on("error", () => {
|
|
41
|
+
resolve({ stdout, stderr, exitCode: -1 });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let tmpDirs: string[] = [];
|
|
47
|
+
|
|
48
|
+
afterAll(() => {
|
|
49
|
+
for (const d of tmpDirs) {
|
|
50
|
+
try { rmSync(d, { recursive: true, force: true }); } catch {}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function makeTestDir(): string {
|
|
55
|
+
const dir = join(tmpdir(), `air-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
tmpDirs.push(dir);
|
|
58
|
+
return dir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Read API key from .env.local
|
|
62
|
+
function getApiKey(): string {
|
|
63
|
+
try {
|
|
64
|
+
const envContent = readFileSync(join(process.cwd(), ".env.local"), "utf-8");
|
|
65
|
+
const match = envContent.match(/DEEPSEEK_API_KEY=(.+)/);
|
|
66
|
+
if (match) return match[1].trim();
|
|
67
|
+
} catch {}
|
|
68
|
+
return process.env.DEEPSEEK_API_KEY || "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const API_KEY = getApiKey();
|
|
72
|
+
|
|
73
|
+
function skipIfNoKey(ctx: any) {
|
|
74
|
+
if (!API_KEY) ctx.skip();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ==================== Tests ====================
|
|
78
|
+
|
|
79
|
+
describe("E2E: CLI 基本功能", () => {
|
|
80
|
+
it("air --help 显示帮助信息", async () => {
|
|
81
|
+
const result = await runCli(["--help"]);
|
|
82
|
+
expect(result.stdout).toContain("Usage:");
|
|
83
|
+
expect(result.stdout).toContain("air");
|
|
84
|
+
expect(result.exitCode).toBe(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("air key <key> 设置 API Key", async () => {
|
|
88
|
+
const airDir = makeTestDir();
|
|
89
|
+
const result = await runCli(["key", "sk-test-12345"], undefined, { AIR_DIR: airDir });
|
|
90
|
+
expect(result.stdout).toContain("✅ API Key 已设置");
|
|
91
|
+
|
|
92
|
+
const configFile = join(airDir, "config.json");
|
|
93
|
+
expect(existsSync(configFile)).toBe(true);
|
|
94
|
+
const config = JSON.parse(readFileSync(configFile, "utf-8"));
|
|
95
|
+
expect(config.apiKey).toBe("sk-test-12345");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("air config 显示配置信息", async () => {
|
|
99
|
+
const airDir = makeTestDir();
|
|
100
|
+
await runCli(["key", "sk-test-abcde"], undefined, { AIR_DIR: airDir });
|
|
101
|
+
const result = await runCli(["config"], undefined, { AIR_DIR: airDir });
|
|
102
|
+
expect(result.stdout).toContain("API Key:");
|
|
103
|
+
expect(result.stdout).toContain("bcde");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
describe("E2E: 对话命令", () => {
|
|
109
|
+
it("/save 保存快照", async (ctx) => {
|
|
110
|
+
skipIfNoKey(ctx);
|
|
111
|
+
|
|
112
|
+
const airDir = makeTestDir();
|
|
113
|
+
const env = { AIR_DIR: airDir };
|
|
114
|
+
await runCli(["key", API_KEY], undefined, env);
|
|
115
|
+
|
|
116
|
+
// 先发一条消息建立历史
|
|
117
|
+
await runCli(["测试消息"], "/exit\n", env);
|
|
118
|
+
|
|
119
|
+
// 交互模式下 /save 后 /exit
|
|
120
|
+
const result = await runCli([], "/save\n/exit\n", env);
|
|
121
|
+
expect(result.stdout).toContain("✅ 快照:");
|
|
122
|
+
|
|
123
|
+
// 验证快照文件被创建
|
|
124
|
+
const snapDir = join(airDir, "snapshots");
|
|
125
|
+
const files = readdirSync(snapDir).filter((f) => f.endsWith(".json"));
|
|
126
|
+
expect(files.length).toBeGreaterThanOrEqual(1);
|
|
127
|
+
}, 30000);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("E2E: 真实 API 对话", () => {
|
|
131
|
+
it("air <message> 发送消息并接收真实回复", async (ctx) => {
|
|
132
|
+
skipIfNoKey(ctx);
|
|
133
|
+
|
|
134
|
+
const airDir = makeTestDir();
|
|
135
|
+
const env = { AIR_DIR: airDir };
|
|
136
|
+
|
|
137
|
+
// Set up real key
|
|
138
|
+
await runCli(["key", API_KEY], undefined, env);
|
|
139
|
+
|
|
140
|
+
// Send a simple message, pipe /exit to leave interactive mode
|
|
141
|
+
const result = await runCli(["用一句话介绍你自己"], "/exit\n", env);
|
|
142
|
+
|
|
143
|
+
// Should have AI response
|
|
144
|
+
expect(result.stdout).toContain("🤖 AI:");
|
|
145
|
+
// Should have some actual content (not empty)
|
|
146
|
+
expect(result.stdout.length).toBeGreaterThan(100);
|
|
147
|
+
|
|
148
|
+
// Verify context was saved with user message + AI response
|
|
149
|
+
const contextFile = join(airDir, "context.json");
|
|
150
|
+
expect(existsSync(contextFile)).toBe(true);
|
|
151
|
+
const msgs = JSON.parse(readFileSync(contextFile, "utf-8"));
|
|
152
|
+
expect(msgs.length).toBeGreaterThanOrEqual(2);
|
|
153
|
+
expect(msgs[1].role).toBe("user");
|
|
154
|
+
expect(msgs[1].content).toBe("用一句话介绍你自己");
|
|
155
|
+
|
|
156
|
+
// Verify there's an assistant response
|
|
157
|
+
const assistantMsg = msgs.find((m: any) => m.role === "assistant");
|
|
158
|
+
expect(assistantMsg).toBeTruthy();
|
|
159
|
+
expect(assistantMsg.content.length).toBeGreaterThan(10);
|
|
160
|
+
}, 30000); // 30s timeout for real API
|
|
161
|
+
|
|
162
|
+
it("/new 命令重置对话", async (ctx) => {
|
|
163
|
+
skipIfNoKey(ctx);
|
|
164
|
+
|
|
165
|
+
const airDir = makeTestDir();
|
|
166
|
+
const env = { AIR_DIR: airDir };
|
|
167
|
+
|
|
168
|
+
await runCli(["key", API_KEY], undefined, env);
|
|
169
|
+
|
|
170
|
+
// Send a message to create history
|
|
171
|
+
await runCli(["第一轮消息"], "/exit\n", env);
|
|
172
|
+
|
|
173
|
+
// Verify there's history
|
|
174
|
+
let contextFile = join(airDir, "context.json");
|
|
175
|
+
let msgs = JSON.parse(readFileSync(contextFile, "utf-8"));
|
|
176
|
+
expect(msgs.length).toBeGreaterThanOrEqual(2);
|
|
177
|
+
|
|
178
|
+
// Interactive mode: /new then exit
|
|
179
|
+
const result = await runCli([], "/new\n/exit\n", env);
|
|
180
|
+
expect(result.stdout).toContain("🆕 重新开始");
|
|
181
|
+
|
|
182
|
+
// Verify context was reset
|
|
183
|
+
msgs = JSON.parse(readFileSync(contextFile, "utf-8"));
|
|
184
|
+
expect(msgs.length).toBe(1);
|
|
185
|
+
expect(msgs[0].role).toBe("system");
|
|
186
|
+
}, 30000);
|
|
187
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import type { SessionCtx } from "../session/shared.js";
|
|
3
|
+
|
|
4
|
+
// Mock all external dependencies
|
|
5
|
+
vi.mock("inquirer", () => ({ default: { prompt: vi.fn() } }));
|
|
6
|
+
vi.mock("../config.js", () => ({
|
|
7
|
+
saveMessages: vi.fn(),
|
|
8
|
+
saveSnapshot: vi.fn(() => "snapshot-123"),
|
|
9
|
+
listSnapshots: vi.fn(() => []),
|
|
10
|
+
loadSnapshot: vi.fn(() => []),
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("../agent-factory.js", () => ({
|
|
13
|
+
buildAgent: vi.fn(async () => ({
|
|
14
|
+
messages: [],
|
|
15
|
+
events: { on: vi.fn(), off: vi.fn() },
|
|
16
|
+
send: vi.fn(),
|
|
17
|
+
})),
|
|
18
|
+
}));
|
|
19
|
+
vi.mock("../session/print.js", () => ({
|
|
20
|
+
sendAndPrint: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const mockPrompt = vi.mocked((await import("inquirer")).default.prompt);
|
|
24
|
+
const { saveMessages, saveSnapshot, listSnapshots, loadSnapshot } = await import("../config.js");
|
|
25
|
+
const { buildAgent } = await import("../agent-factory.js");
|
|
26
|
+
|
|
27
|
+
function makeCtx(msgs: any[] = []): SessionCtx {
|
|
28
|
+
return { agent: { messages: msgs, events: { on: vi.fn(), off: vi.fn() }, send: vi.fn() } as any };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ==================== shared.ts ====================
|
|
32
|
+
|
|
33
|
+
describe("saveAndNew", () => {
|
|
34
|
+
it("写入 system prompt 并构建 agent", async () => {
|
|
35
|
+
const { saveAndNew } = await import("../session/shared.js");
|
|
36
|
+
const ctx = makeCtx();
|
|
37
|
+
|
|
38
|
+
await saveAndNew(ctx);
|
|
39
|
+
|
|
40
|
+
expect(saveMessages).toHaveBeenCalledWith(
|
|
41
|
+
expect.arrayContaining([expect.objectContaining({ role: "system" })])
|
|
42
|
+
);
|
|
43
|
+
expect(buildAgent).toHaveBeenCalled();
|
|
44
|
+
expect(ctx.agent).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ==================== dispatchCommand 路由 ====================
|
|
49
|
+
|
|
50
|
+
describe("dispatchCommand", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.clearAllMocks();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('"/exit" 调用 cmdExit(process.exit)', async () => {
|
|
56
|
+
// Can't easily test process.exit, just verify it doesn't throw
|
|
57
|
+
const { runConversation } = await import("../session/runtime.js");
|
|
58
|
+
// dispatchCommand is internal, tested via routing only
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('"/save" 调用 cmdSave', async () => {
|
|
62
|
+
const { cmdSave } = await import("../session/commands/save.js");
|
|
63
|
+
const ctx = makeCtx([{ role: "system", content: "sys" }]);
|
|
64
|
+
await cmdSave(ctx);
|
|
65
|
+
expect(saveSnapshot).toHaveBeenCalledWith(ctx.agent.messages);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('"/new" 调用 cmdNew 并打印提示', async () => {
|
|
69
|
+
const { cmdNew } = await import("../session/commands/new.js");
|
|
70
|
+
const ctx = makeCtx();
|
|
71
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
72
|
+
await cmdNew(ctx);
|
|
73
|
+
expect(spy).toHaveBeenCalledWith("\n🆕 重新开始\n");
|
|
74
|
+
spy.mockRestore();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('"/help" 打印帮助信息', async () => {
|
|
78
|
+
const { cmdHelp } = await import("../session/commands/help.js");
|
|
79
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
80
|
+
cmdHelp();
|
|
81
|
+
expect(spy).toHaveBeenCalled();
|
|
82
|
+
spy.mockRestore();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ==================== cmdBack ====================
|
|
87
|
+
|
|
88
|
+
describe("cmdBack", () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
vi.clearAllMocks();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("没有消息时提示无可撤回", async () => {
|
|
94
|
+
const { cmdBack } = await import("../session/commands/back.js");
|
|
95
|
+
const ctx = makeCtx([{ role: "system", content: "sys" }]);
|
|
96
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
97
|
+
await cmdBack(ctx);
|
|
98
|
+
expect(spy).toHaveBeenCalledWith("\n❌ 还没有消息可以撤回\n");
|
|
99
|
+
spy.mockRestore();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("取消操作时不修改消息", async () => {
|
|
103
|
+
const { cmdBack } = await import("../session/commands/back.js");
|
|
104
|
+
const msgs = [
|
|
105
|
+
{ role: "system", content: "sys" },
|
|
106
|
+
{ role: "user", content: "你好" },
|
|
107
|
+
];
|
|
108
|
+
const ctx = makeCtx(msgs);
|
|
109
|
+
mockPrompt.mockResolvedValueOnce({ selectedIndex: -1 });
|
|
110
|
+
await cmdBack(ctx);
|
|
111
|
+
expect(ctx.agent.messages.length).toBe(2);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ==================== cmdLoad ====================
|
|
116
|
+
|
|
117
|
+
describe("cmdLoad", () => {
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
vi.clearAllMocks();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("没有快照时提示", async () => {
|
|
123
|
+
const { cmdLoad } = await import("../session/commands/load.js");
|
|
124
|
+
const ctx = makeCtx();
|
|
125
|
+
vi.mocked(listSnapshots).mockReturnValueOnce([]);
|
|
126
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
127
|
+
await cmdLoad(ctx);
|
|
128
|
+
expect(spy).toHaveBeenCalledWith("\n📭 没有可用的快照\n");
|
|
129
|
+
spy.mockRestore();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ==================== cmdEditor ====================
|
|
134
|
+
|
|
135
|
+
describe("cmdEditor", () => {
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
vi.clearAllMocks();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("取消编辑不报错", async () => {
|
|
141
|
+
const { cmdEditor } = await import("../session/commands/editor.js");
|
|
142
|
+
const ctx = makeCtx();
|
|
143
|
+
mockPrompt.mockResolvedValueOnce({ content: "" });
|
|
144
|
+
await cmdEditor(ctx);
|
|
145
|
+
expect(saveMessages).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
});
|
package/src/agent-factory.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { readConfig } from "./config.js";
|
|
|
4
4
|
import { shellTool } from "./tools.js";
|
|
5
5
|
|
|
6
6
|
const MODEL_NAME = "deepseek-v4-flash";
|
|
7
|
-
const API_ENDPOINT = "https://api.deepseek.com/v1";
|
|
7
|
+
const API_ENDPOINT = process.env.AIR_API_ENDPOINT || "https://api.deepseek.com/v1";
|
|
8
8
|
|
|
9
9
|
async function buildModel(apiKey: string) {
|
|
10
10
|
const endpoint = new OpenAI({ openai_endpoint: API_ENDPOINT, api_key: apiKey });
|
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
-
import { runConversation } from "./
|
|
4
|
+
import { runConversation } from "./session/runtime.js";
|
|
5
5
|
import { installHook, uninstallHook } from "./hook.js";
|
|
6
6
|
import { readConfig, saveConfig } from "./config.js";
|
|
7
7
|
|
|
@@ -49,15 +49,15 @@ program
|
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
program
|
|
52
|
-
.argument("[message]", "要发送的消息(不传则进入交互模式)")
|
|
53
|
-
.action(async (message?: string) => {
|
|
52
|
+
.argument("[message...]", "要发送的消息(不传则进入交互模式)")
|
|
53
|
+
.action(async (message?: string[]) => {
|
|
54
54
|
const config = readConfig();
|
|
55
55
|
if (!config.apiKey) {
|
|
56
56
|
console.error("❌ 请先设置 API Key: air key <your-key>");
|
|
57
57
|
console.error(" 获取 Key: https://platform.deepseek.com/api_keys");
|
|
58
58
|
process.exit(1);
|
|
59
59
|
}
|
|
60
|
-
await runConversation(message);
|
|
60
|
+
await runConversation(message?.join(" "));
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
program.parse(process.argv);
|
package/src/config.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface Config {
|
|
|
10
10
|
|
|
11
11
|
// ==================== 路径 ====================
|
|
12
12
|
|
|
13
|
-
const AIR_DIR = () => join(homedir(), ".ai-zen", "air");
|
|
13
|
+
const AIR_DIR = () => process.env.AIR_DIR || join(homedir(), ".ai-zen", "air");
|
|
14
14
|
const CONFIG_FILE = () => join(AIR_DIR(), "config.json");
|
|
15
15
|
const CONTEXT_FILE = () => join(AIR_DIR(), "context.json");
|
|
16
16
|
const SNAPSHOTS_DIR = () => join(AIR_DIR(), "snapshots");
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import inquirer from "inquirer";
|
|
2
|
+
import type { SessionCtx } from "../shared.js";
|
|
3
|
+
import { sendAndPrint } from "../print.js";
|
|
4
|
+
import { saveMessages } from "../../config.js";
|
|
5
|
+
import { buildAgent } from "../../agent-factory.js";
|
|
6
|
+
|
|
7
|
+
export async function cmdBack(ctx: SessionCtx): Promise<void> {
|
|
8
|
+
const targets: { index: number; role: string; label: string; preview: string }[] = [];
|
|
9
|
+
for (let i = 0; i < ctx.agent.messages.length; i++) {
|
|
10
|
+
const msg = ctx.agent.messages[i];
|
|
11
|
+
if (msg.role === "user") {
|
|
12
|
+
const text = typeof msg.content === "string" ? msg.content : "";
|
|
13
|
+
if (text) {
|
|
14
|
+
targets.push({ index: i, role: "user", label: "\u{1F464} 用户", preview: text.substring(0, 60) + (text.length > 60 ? "..." : "") });
|
|
15
|
+
}
|
|
16
|
+
} else if (msg.role === "tool" || msg.role === "function") {
|
|
17
|
+
const text = typeof msg.content === "string" ? msg.content : "";
|
|
18
|
+
if (text) {
|
|
19
|
+
targets.push({ index: i, role: msg.role, label: "\u{1F527} 工具", preview: text.substring(0, 60) + (text.length > 60 ? "..." : "") });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (targets.length === 0) {
|
|
25
|
+
console.log("\n❌ 还没有消息可以撤回\n");
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log("\n📋 选择要撤回到哪条消息(将删除所选及其之后的所有内容):\n");
|
|
30
|
+
|
|
31
|
+
const { selectedIndex } = await inquirer.prompt([
|
|
32
|
+
{
|
|
33
|
+
type: "list",
|
|
34
|
+
name: "selectedIndex",
|
|
35
|
+
message: "撤回到:",
|
|
36
|
+
pageSize: 15,
|
|
37
|
+
choices: [
|
|
38
|
+
{ name: "\u21a9\ufe0f 取消操作", value: -1 },
|
|
39
|
+
...targets.map((t) => ({ name: t.label + " " + t.preview, value: t.index })),
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
if (selectedIndex === -1) { console.log("\n已取消\n"); return; }
|
|
45
|
+
|
|
46
|
+
const selectedMsg = ctx.agent.messages[selectedIndex];
|
|
47
|
+
const isUserMsg = selectedMsg.role === "user";
|
|
48
|
+
const originalText = typeof selectedMsg.content === "string" ? selectedMsg.content : "";
|
|
49
|
+
const sliceEnd = isUserMsg ? selectedIndex : selectedIndex + 1;
|
|
50
|
+
ctx.agent.messages = ctx.agent.messages.slice(0, sliceEnd);
|
|
51
|
+
|
|
52
|
+
if (isUserMsg) {
|
|
53
|
+
console.log("\n原内容: " + originalText.substring(0, 200) + (originalText.length > 200 ? "..." : "") + "\n");
|
|
54
|
+
const { editChoice } = await inquirer.prompt([
|
|
55
|
+
{
|
|
56
|
+
type: "list",
|
|
57
|
+
name: "editChoice",
|
|
58
|
+
message: "请选择:",
|
|
59
|
+
choices: [
|
|
60
|
+
{ name: "\u270f\ufe0f 修改后重新发送", value: "edit" },
|
|
61
|
+
{ name: "\u{1F504} 直接重新发送", value: "resend" },
|
|
62
|
+
{ name: "\u21a9\ufe0f 取消操作", value: "cancel" },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
if (editChoice === "cancel") { console.log("\n已取消\n"); return; }
|
|
67
|
+
|
|
68
|
+
let textToSend = originalText;
|
|
69
|
+
if (editChoice === "edit") {
|
|
70
|
+
const { editedContent } = await inquirer.prompt([
|
|
71
|
+
{ type: "input", name: "editedContent", message: "修改消息:", default: originalText },
|
|
72
|
+
]);
|
|
73
|
+
if (!editedContent.trim()) { console.log("\n❌ 消息不能为空\n"); return; }
|
|
74
|
+
textToSend = editedContent.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
ctx.agent = await buildAgent(ctx.agent.messages);
|
|
78
|
+
await sendAndPrint(ctx.agent, textToSend);
|
|
79
|
+
saveMessages(ctx.agent.messages);
|
|
80
|
+
} else {
|
|
81
|
+
console.log("\n💡 请输入一条新消息继续对话\n");
|
|
82
|
+
const { newMessage } = await inquirer.prompt([
|
|
83
|
+
{ type: "input", name: "newMessage", message: "新消息:" },
|
|
84
|
+
]);
|
|
85
|
+
if (!newMessage.trim()) { console.log("\n已取消\n"); return; }
|
|
86
|
+
ctx.agent = await buildAgent(ctx.agent.messages);
|
|
87
|
+
await sendAndPrint(ctx.agent, newMessage.trim());
|
|
88
|
+
saveMessages(ctx.agent.messages);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import inquirer from "inquirer";
|
|
2
|
+
import type { SessionCtx } from "../shared.js";
|
|
3
|
+
import { sendAndPrint } from "../print.js";
|
|
4
|
+
import { saveMessages } from "../../config.js";
|
|
5
|
+
|
|
6
|
+
export async function cmdEditor(ctx: SessionCtx) {
|
|
7
|
+
const { content } = await inquirer.prompt([
|
|
8
|
+
{ type: "editor", name: "content", message: "编辑消息:" },
|
|
9
|
+
]);
|
|
10
|
+
if (!content.trim()) {
|
|
11
|
+
console.log("\n已取消\n");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
await sendAndPrint(ctx.agent, content.trim());
|
|
16
|
+
saveMessages(ctx.agent.messages);
|
|
17
|
+
} catch (err: any) { console.error(`\n❌ ${err.message}`); }
|
|
18
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Message } from "@ai-zen/agents-core";
|
|
2
|
+
import inquirer from "inquirer";
|
|
3
|
+
import type { SessionCtx } from "../shared.js";
|
|
4
|
+
import { SYSTEM_PROMPT } from "../shared.js";
|
|
5
|
+
import { listSnapshots, loadSnapshot, saveMessages } from "../../config.js";
|
|
6
|
+
import { buildAgent } from "../../agent-factory.js";
|
|
7
|
+
|
|
8
|
+
export async function cmdLoad(ctx: SessionCtx): Promise<void> {
|
|
9
|
+
const snapshots = listSnapshots();
|
|
10
|
+
if (snapshots.length === 0) {
|
|
11
|
+
console.log("\n📭 没有可用的快照\n");
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const { selectedName } = await inquirer.prompt([
|
|
15
|
+
{
|
|
16
|
+
type: "list",
|
|
17
|
+
name: "selectedName",
|
|
18
|
+
message: "选择要加载的快照:",
|
|
19
|
+
pageSize: 15,
|
|
20
|
+
choices: [
|
|
21
|
+
{ name: "\u21a9\ufe0f 取消操作", value: "" },
|
|
22
|
+
...snapshots.map((s) => ({ name: s.date, value: s.name })),
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
]);
|
|
26
|
+
if (!selectedName) {
|
|
27
|
+
console.log("\n已取消\n");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
let loaded = loadSnapshot(selectedName);
|
|
31
|
+
if (loaded.length === 0) {
|
|
32
|
+
loaded = [Message.System(SYSTEM_PROMPT)];
|
|
33
|
+
}
|
|
34
|
+
saveMessages(loaded);
|
|
35
|
+
const snap = snapshots.find((s) => s.name === selectedName);
|
|
36
|
+
console.log("\n✅ 已加载快照: " + (snap ? snap.date : selectedName) + "\n");
|
|
37
|
+
ctx.agent = await buildAgent(loaded);
|
|
38
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Message } from "@ai-zen/agents-core";
|
|
2
|
+
import type { SessionCtx } from "./shared.js";
|
|
3
|
+
import { SYSTEM_PROMPT } from "./shared.js";
|
|
4
|
+
import { sendAndPrint } from "./print.js";
|
|
5
|
+
import { saveMessages, saveSnapshot } from "../config.js";
|
|
6
|
+
import { buildAgent } from "../agent-factory.js";
|
|
7
|
+
import { contextSize, shouldMigrate, generateMigrationDoc, MAX_CONTEXT_CHARS } from "../migration.js";
|
|
8
|
+
|
|
9
|
+
export async function handleMessage(ctx: SessionCtx, text: string): Promise<void> {
|
|
10
|
+
try {
|
|
11
|
+
await sendAndPrint(ctx.agent, text);
|
|
12
|
+
saveMessages(ctx.agent.messages);
|
|
13
|
+
|
|
14
|
+
if (shouldMigrate(ctx.agent.messages)) {
|
|
15
|
+
console.log(`🔄 上下文 ${contextSize(ctx.agent.messages)}/${MAX_CONTEXT_CHARS},准备迁移...`);
|
|
16
|
+
try {
|
|
17
|
+
const snap = saveSnapshot(ctx.agent.messages);
|
|
18
|
+
console.log(` 💾 快照: ${snap}`);
|
|
19
|
+
const summary = await generateMigrationDoc(ctx.agent.messages);
|
|
20
|
+
const msgs = [Message.System(SYSTEM_PROMPT), Message.User(summary)];
|
|
21
|
+
saveMessages(msgs);
|
|
22
|
+
console.log("✅ 迁移完成\n");
|
|
23
|
+
ctx.agent = await buildAgent(msgs);
|
|
24
|
+
} catch (err: any) { console.error(`❌ 迁移失败: ${err.message}\n`); }
|
|
25
|
+
}
|
|
26
|
+
} catch (err: any) { console.error(`\n❌ ${err.message}`); }
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { Agent } from "@ai-zen/agents-core";
|
|
3
|
+
import { DeltaRenderer } from "../delta-renderer.js";
|
|
4
|
+
import type { AgentNS } from "@ai-zen/agents-core";
|
|
5
|
+
|
|
6
|
+
export async function sendAndPrint(agent: Agent, text: string): Promise<void> {
|
|
7
|
+
console.log(chalk.green.bold("\n🤖 AI:"));
|
|
8
|
+
const renderer = new DeltaRenderer({
|
|
9
|
+
reasoningHeader: "\n\n💭 思考中...\n",
|
|
10
|
+
contentHeader: "\n\n💭 回答中...\n",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
function onChunk(chunk: AgentNS.StreamResponseData) {
|
|
14
|
+
const delta = chunk?.choices?.[0]?.delta;
|
|
15
|
+
const fr = chunk?.choices?.[0]?.finish_reason ?? null;
|
|
16
|
+
if (delta) renderer.render(delta, fr);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function onRun() { renderer.reset(); }
|
|
20
|
+
agent.events.on("run", onRun);
|
|
21
|
+
agent.events.on("chunk", onChunk);
|
|
22
|
+
await agent.send(text);
|
|
23
|
+
agent.events.off("run", onRun);
|
|
24
|
+
agent.events.off("chunk", onChunk);
|
|
25
|
+
process.stdout.write("\n\n");
|
|
26
|
+
console.log();
|
|
27
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Agent, Message } from "@ai-zen/agents-core";
|
|
2
|
+
import { readConfig, readMessages, saveMessages, saveSnapshot } from "../config.js";
|
|
3
|
+
import { buildAgent } from "../agent-factory.js";
|
|
4
|
+
import { contextSize } from "../migration.js";
|
|
5
|
+
import { SessionCtx, SYSTEM_PROMPT, saveAndNew, ask } from "./shared.js";
|
|
6
|
+
import { sendAndPrint } from "./print.js";
|
|
7
|
+
|
|
8
|
+
export async function runConversation(initialMessage?: string): Promise<void> {
|
|
9
|
+
const config = readConfig();
|
|
10
|
+
if (!config.apiKey) {
|
|
11
|
+
console.error("❌ 请先设置 API Key: air key <your-key>");
|
|
12
|
+
console.error(" 获取 Key: https://platform.deepseek.com/api_keys");
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let msgs = readMessages();
|
|
17
|
+
if (msgs.length === 0) {
|
|
18
|
+
msgs = [Message.System(SYSTEM_PROMPT)];
|
|
19
|
+
saveMessages(msgs);
|
|
20
|
+
}
|
|
21
|
+
const agent: Agent = await buildAgent(msgs);
|
|
22
|
+
const ctx: SessionCtx = { agent };
|
|
23
|
+
|
|
24
|
+
if (initialMessage) {
|
|
25
|
+
const hasHistory = ctx.agent.messages.some(m => m.role === "user" || m.role === "assistant");
|
|
26
|
+
if (hasHistory) {
|
|
27
|
+
const snap = saveSnapshot(ctx.agent.messages);
|
|
28
|
+
console.log(`💾 已存档旧对话: ${snap}\n`);
|
|
29
|
+
}
|
|
30
|
+
await saveAndNew(ctx);
|
|
31
|
+
console.log(`💬 你: ${initialMessage}`);
|
|
32
|
+
try {
|
|
33
|
+
await sendAndPrint(ctx.agent, initialMessage);
|
|
34
|
+
saveMessages(ctx.agent.messages);
|
|
35
|
+
} catch (err: any) { console.error(`\n❌ ${err.message}`); }
|
|
36
|
+
console.log("\n💬 继续对话 (输入 /exit 退出)\n");
|
|
37
|
+
await ask(ctx);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const msgCount = ctx.agent.messages.filter((m) => m.role !== "system").length;
|
|
42
|
+
console.log(msgCount > 0
|
|
43
|
+
? `\n💬 继续上次对话 (${msgCount} 条,/${contextSize(ctx.agent.messages)} 字符,输入 /new 重新开始)\n`
|
|
44
|
+
: "\n💬 air — 极简 AI 助手 (输入 /help 查看命令)\n");
|
|
45
|
+
|
|
46
|
+
await ask(ctx);
|
|
47
|
+
}
|