chatccc 0.2.173 → 0.2.174
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/README.md +3 -2
- package/package.json +1 -1
- package/src/__tests__/feishu-avatar.test.ts +30 -3
- package/src/__tests__/feishu-platform.test.ts +16 -8
- package/src/__tests__/orchestrator.test.ts +93 -11
- package/src/__tests__/web-ui.test.ts +10 -1
- package/src/cards.ts +4 -3
- package/src/feishu-api.ts +207 -92
- package/src/feishu-platform.ts +15 -10
- package/src/orchestrator.ts +112 -26
- package/src/sim-platform.ts +14 -7
- package/src/web-ui.ts +6 -4
package/README.md
CHANGED
|
@@ -308,8 +308,9 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
308
308
|
| `/stop` | 停止当前回复 |
|
|
309
309
|
| `/state` | 查看当前会话状态 |
|
|
310
310
|
| `/cd` | 查看或设置当前会话工作目录 |
|
|
311
|
-
| `/sessions` | 查看所有会话状态 |
|
|
312
|
-
| `/
|
|
311
|
+
| `/sessions` | 查看所有会话状态 |
|
|
312
|
+
| `/usage` | 查看 Codex 5h 用量和周用量 |
|
|
313
|
+
| `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
|
|
313
314
|
| `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
|
|
314
315
|
| `/ask <内容>` | 只读问答模式:与 /plan 相同,仅允许读文件和 stop-stuck-loop 请求 |
|
|
315
316
|
| `/restart` | 重启机器人进程 |
|
package/package.json
CHANGED
|
@@ -61,20 +61,47 @@ describe("Codex avatar usage battery", () => {
|
|
|
61
61
|
vi.restoreAllMocks();
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
it("adds
|
|
64
|
+
it("adds weekly battery and 5h ring percentages to Codex avatar uploads when usage lookup succeeds", async () => {
|
|
65
65
|
const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
|
|
66
66
|
const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
|
|
67
67
|
const uploadedNames: string[] = [];
|
|
68
68
|
await writeCodexAuth(homeDir);
|
|
69
69
|
mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
|
|
70
|
-
rate_limit: {
|
|
70
|
+
rate_limit: {
|
|
71
|
+
primary_window: { used_percent: 37 },
|
|
72
|
+
secondary_window: { used_percent: 12 },
|
|
73
|
+
},
|
|
71
74
|
}), { status: 200 }));
|
|
72
75
|
|
|
73
76
|
try {
|
|
74
77
|
const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
|
|
75
78
|
await setChatAvatar("tenant-token", "chat_1", "codex", "busy");
|
|
76
79
|
|
|
77
|
-
expect(uploadedNames).toEqual(["
|
|
80
|
+
expect(uploadedNames).toEqual(["avatar_codex_busy_week_88_5h_63.jpg"]);
|
|
81
|
+
} finally {
|
|
82
|
+
await rm(homeDir, { recursive: true, force: true });
|
|
83
|
+
await rm(userDataDir, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns both 5h and weekly Codex usage windows", async () => {
|
|
88
|
+
const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
|
|
89
|
+
const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
|
|
90
|
+
const uploadedNames: string[] = [];
|
|
91
|
+
await writeCodexAuth(homeDir);
|
|
92
|
+
mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
|
|
93
|
+
rate_limit: {
|
|
94
|
+
primary_window: { used_percent: 37, reset_after_seconds: 10349, reset_at: 1781528212 },
|
|
95
|
+
secondary_window: { used_percent: 12, reset_after_seconds: 325063, reset_at: 1781842926 },
|
|
96
|
+
},
|
|
97
|
+
}), { status: 200 }));
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const { getCodexUsageSummary } = await loadFeishuApiWithHome(homeDir, userDataDir);
|
|
101
|
+
await expect(getCodexUsageSummary()).resolves.toEqual({
|
|
102
|
+
fiveHour: { usedPercent: 37, remainingPercent: 63, resetAfterSeconds: 10349, resetAtEpochSeconds: 1781528212 },
|
|
103
|
+
weekly: { usedPercent: 12, remainingPercent: 88, resetAfterSeconds: 325063, resetAtEpochSeconds: 1781842926 },
|
|
104
|
+
});
|
|
78
105
|
} finally {
|
|
79
106
|
await rm(homeDir, { recursive: true, force: true });
|
|
80
107
|
await rm(userDataDir, { recursive: true, force: true });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
2
|
-
import { getPlatform, setPlatform, getTenantAccessToken, getChatInfo, createGroupChat, updateChatInfo, sendTextReply, sendCardReply, sendRawCard, sendPostMessage, extractSessionInfo, formatDelayNotice, reportPermissionResults, verifyAllPermissions, addReaction, recallMessage, updateCardMessage, setChatAvatar, disbandChat, sendRestartCard, getMergeForwardMessages } from "../feishu-platform.ts";
|
|
2
|
+
import { getPlatform, setPlatform, getTenantAccessToken, getChatInfo, createGroupChat, updateChatInfo, sendTextReply, sendCardReply, sendRawCard, sendPostMessage, extractSessionInfo, formatDelayNotice, reportPermissionResults, verifyAllPermissions, addReaction, recallMessage, updateCardMessage, setChatAvatar, getCodexUsageSummary, disbandChat, sendRestartCard, getMergeForwardMessages } from "../feishu-platform.ts";
|
|
3
3
|
import type { FeishuPlatform } from "../feishu-platform.ts";
|
|
4
4
|
|
|
5
5
|
const realPlatform = getPlatform();
|
|
@@ -26,9 +26,13 @@ describe("feishu-platform", () => {
|
|
|
26
26
|
createGroupChat: async () => "mock_chat",
|
|
27
27
|
updateChatInfo: async () => {},
|
|
28
28
|
getChatInfo: async () => ({ name: "x", description: "y" }),
|
|
29
|
-
disbandChat: async () => {},
|
|
30
|
-
setChatAvatar: async () => {},
|
|
31
|
-
|
|
29
|
+
disbandChat: async () => {},
|
|
30
|
+
setChatAvatar: async () => {},
|
|
31
|
+
getCodexUsageSummary: async () => ({
|
|
32
|
+
fiveHour: { usedPercent: 10, remainingPercent: 90, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
|
|
33
|
+
weekly: { usedPercent: 20, remainingPercent: 80, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
|
|
34
|
+
}),
|
|
35
|
+
getOrDownloadImage: async () => "/tmp/img.png",
|
|
32
36
|
verifyAllPermissions: async () => [],
|
|
33
37
|
reportPermissionResults: realPlatform.reportPermissionResults,
|
|
34
38
|
extractSessionInfo: realPlatform.extractSessionInfo,
|
|
@@ -46,9 +50,13 @@ describe("feishu-platform", () => {
|
|
|
46
50
|
expect(await getChatInfo("t", "mock_chat")).toEqual({ name: "x", description: "y" });
|
|
47
51
|
const perms = await verifyAllPermissions("t");
|
|
48
52
|
expect(perms).toEqual([]);
|
|
49
|
-
const mergeMsgs = await getMergeForwardMessages("t", "om_test");
|
|
50
|
-
expect(mergeMsgs).toEqual([]);
|
|
51
|
-
|
|
53
|
+
const mergeMsgs = await getMergeForwardMessages("t", "om_test");
|
|
54
|
+
expect(mergeMsgs).toEqual([]);
|
|
55
|
+
expect(await getCodexUsageSummary()).toEqual({
|
|
56
|
+
fiveHour: { usedPercent: 10, remainingPercent: 90, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
|
|
57
|
+
weekly: { usedPercent: 20, remainingPercent: 80, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
|
|
58
|
+
});
|
|
59
|
+
} finally {
|
|
52
60
|
// 恢复到真实实现,避免影响后续测试
|
|
53
61
|
setPlatform(realPlatform);
|
|
54
62
|
}
|
|
@@ -58,4 +66,4 @@ describe("feishu-platform", () => {
|
|
|
58
66
|
setPlatform(realPlatform);
|
|
59
67
|
expect(getPlatform()).toBe(realPlatform);
|
|
60
68
|
});
|
|
61
|
-
});
|
|
69
|
+
});
|
|
@@ -6,7 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
7
7
|
import type { SessionInfo, ToolAdapter } from "../adapters/adapter-interface.ts";
|
|
8
8
|
|
|
9
|
-
const mockStreamStates = new Map<string, { status: "running" | "done" | "stopped"; finalReply: string }>();
|
|
9
|
+
const mockStreamStates = new Map<string, { status: "running" | "done" | "stopped"; finalReply: string }>();
|
|
10
|
+
const mockGetCodexUsageSummary = vi.hoisted(() => vi.fn());
|
|
10
11
|
|
|
11
12
|
vi.mock("../im-skills.ts", () => ({
|
|
12
13
|
buildImSkillsPrompt: async () => "",
|
|
@@ -14,7 +15,7 @@ vi.mock("../im-skills.ts", () => ({
|
|
|
14
15
|
exportSkillSubDocs: async () => {},
|
|
15
16
|
}));
|
|
16
17
|
|
|
17
|
-
vi.mock("../stream-state.ts", () => ({
|
|
18
|
+
vi.mock("../stream-state.ts", () => ({
|
|
18
19
|
readStreamState: async (sessionId: string) => {
|
|
19
20
|
const state = mockStreamStates.get(sessionId);
|
|
20
21
|
if (!state) return null;
|
|
@@ -49,8 +50,14 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
49
50
|
cwd,
|
|
50
51
|
tool,
|
|
51
52
|
}),
|
|
52
|
-
fixStaleStreamStates: async () => {},
|
|
53
|
-
}));
|
|
53
|
+
fixStaleStreamStates: async () => {},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock("../feishu-platform.ts", () => ({
|
|
57
|
+
getCodexUsageSummary: mockGetCodexUsageSummary,
|
|
58
|
+
getTenantAccessToken: vi.fn(async () => "tenant-token"),
|
|
59
|
+
sendPostMessage: vi.fn(async () => true),
|
|
60
|
+
}));
|
|
54
61
|
|
|
55
62
|
import { handleCommand } from "../orchestrator.ts";
|
|
56
63
|
import {
|
|
@@ -113,9 +120,14 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
113
120
|
_setSessionToolsFileForTest(join(tempDir, "sessions.json"));
|
|
114
121
|
resetState();
|
|
115
122
|
resetBindingState();
|
|
116
|
-
mockStreamStates.clear();
|
|
117
|
-
|
|
118
|
-
|
|
123
|
+
mockStreamStates.clear();
|
|
124
|
+
mockGetCodexUsageSummary.mockReset();
|
|
125
|
+
mockGetCodexUsageSummary.mockResolvedValue({
|
|
126
|
+
fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
127
|
+
weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
128
|
+
});
|
|
129
|
+
_setAdapterForToolForTest("claude", mockAdapter());
|
|
130
|
+
});
|
|
119
131
|
|
|
120
132
|
afterEach(async () => {
|
|
121
133
|
resetState();
|
|
@@ -237,7 +249,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
237
249
|
expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
|
|
238
250
|
});
|
|
239
251
|
|
|
240
|
-
it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
|
|
252
|
+
it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
|
|
241
253
|
const platform = mockPlatform("feishu");
|
|
242
254
|
await recordSessionRegistry({
|
|
243
255
|
chatId: "feishu-p2p",
|
|
@@ -252,6 +264,76 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
252
264
|
expect(platform.createGroup).not.toHaveBeenCalled();
|
|
253
265
|
expect(platform.sendRawCard).toHaveBeenCalled();
|
|
254
266
|
const registry = await loadSessionRegistryForBinding();
|
|
255
|
-
expect(registry["feishu-p2p"]).toBeUndefined();
|
|
256
|
-
});
|
|
257
|
-
|
|
267
|
+
expect(registry["feishu-p2p"]).toBeUndefined();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("handles /usage without creating a new Feishu group", async () => {
|
|
271
|
+
const platform = mockPlatform("feishu");
|
|
272
|
+
mockGetCodexUsageSummary.mockResolvedValue({
|
|
273
|
+
fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
|
|
274
|
+
weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
278
|
+
|
|
279
|
+
expect(platform.createGroup).not.toHaveBeenCalled();
|
|
280
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
281
|
+
"feishu-p2p",
|
|
282
|
+
"Codex Usage",
|
|
283
|
+
expect.stringContaining("**5h:** 已用 37%,剩余 63%,重置:"),
|
|
284
|
+
"blue",
|
|
285
|
+
);
|
|
286
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
287
|
+
"feishu-p2p",
|
|
288
|
+
"Codex Usage",
|
|
289
|
+
expect.stringContaining("约 2小时52分钟后"),
|
|
290
|
+
"blue",
|
|
291
|
+
);
|
|
292
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
293
|
+
"feishu-p2p",
|
|
294
|
+
"Codex Usage",
|
|
295
|
+
expect.stringContaining("[███████░░░░░░░░░░░░░]"),
|
|
296
|
+
"blue",
|
|
297
|
+
);
|
|
298
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
299
|
+
"feishu-p2p",
|
|
300
|
+
"Codex Usage",
|
|
301
|
+
expect.stringContaining("**周:** 已用 12%,剩余 88%,重置:"),
|
|
302
|
+
"blue",
|
|
303
|
+
);
|
|
304
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
305
|
+
"feishu-p2p",
|
|
306
|
+
"Codex Usage",
|
|
307
|
+
expect.stringContaining("约 3天18小时17分钟后"),
|
|
308
|
+
"blue",
|
|
309
|
+
);
|
|
310
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
311
|
+
"feishu-p2p",
|
|
312
|
+
"Codex Usage",
|
|
313
|
+
expect.stringContaining("[██░░░░░░░░░░░░░░░░░░]"),
|
|
314
|
+
"blue",
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("only advertises /usage in new Codex session ready messages", async () => {
|
|
319
|
+
const codexPlatform = mockPlatform("feishu");
|
|
320
|
+
_setAdapterForToolForTest("codex", mockAdapter("sid-codex"));
|
|
321
|
+
|
|
322
|
+
await handleCommand(codexPlatform, "/new codex", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
323
|
+
|
|
324
|
+
expect(codexPlatform.sendCard).toHaveBeenCalledWith(
|
|
325
|
+
"feishu-group",
|
|
326
|
+
"Codex Session Ready",
|
|
327
|
+
expect.stringContaining("发送 **/usage** 查看 Codex 5h 和周用量。"),
|
|
328
|
+
"green",
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const claudePlatform = mockPlatform("feishu");
|
|
332
|
+
await handleCommand(claudePlatform, "/new claude", "feishu-p2p-2", "ou-user", Date.now(), "p2p");
|
|
333
|
+
|
|
334
|
+
const claudeReadyCall = vi.mocked(claudePlatform.sendCard).mock.calls.find(
|
|
335
|
+
([chatId, title]) => chatId === "feishu-group" && title === "Claude Code Session Ready",
|
|
336
|
+
);
|
|
337
|
+
expect(claudeReadyCall?.[2]).not.toContain("/usage");
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
PAGE_HTML,
|
|
3
4
|
chooseStartPath,
|
|
4
5
|
unflattenConfig,
|
|
5
6
|
} from "../web-ui.ts";
|
|
@@ -34,6 +35,14 @@ describe("unflattenConfig", () => {
|
|
|
34
35
|
});
|
|
35
36
|
});
|
|
36
37
|
|
|
38
|
+
describe("dashboard edit modal", () => {
|
|
39
|
+
it("shows the edit modal and overlay when a section edit button is clicked", () => {
|
|
40
|
+
expect(PAGE_HTML).toContain("function editSection(section)");
|
|
41
|
+
expect(PAGE_HTML).toContain("document.getElementById('edit-modal').classList.remove('hidden');");
|
|
42
|
+
expect(PAGE_HTML).toContain("document.getElementById('edit-overlay').classList.remove('hidden');");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
37
46
|
// ---------------------------------------------------------------------------
|
|
38
47
|
// chooseStartPath — /api/start 的路径选择
|
|
39
48
|
// 关键护栏:
|
|
@@ -80,4 +89,4 @@ describe("chooseStartPath", () => {
|
|
|
80
89
|
}),
|
|
81
90
|
).toBe("spawn");
|
|
82
91
|
});
|
|
83
|
-
});
|
|
92
|
+
});
|
package/src/cards.ts
CHANGED
|
@@ -133,9 +133,10 @@ export function buildHelpCard(
|
|
|
133
133
|
"发送 **/new cursor** 创建新 Cursor 会话",
|
|
134
134
|
"发送 **/new codex** 创建新 Codex 会话",
|
|
135
135
|
"发送 **/newh** 重置当前会话(沿用当前工作目录,不切换)",
|
|
136
|
-
"发送 **/plan** 以规划模式提问(只读,不执行写操作)",
|
|
137
|
-
"发送 **/ask** 以问答模式提问(只读,不执行写操作)",
|
|
138
|
-
|
|
136
|
+
"发送 **/plan** 以规划模式提问(只读,不执行写操作)",
|
|
137
|
+
"发送 **/ask** 以问答模式提问(只读,不执行写操作)",
|
|
138
|
+
"发送 **/usage** 查看 Codex 5h 和周用量",
|
|
139
|
+
"发送 **/restart** 重启 ChatCCC 进程",
|
|
139
140
|
"发送 **/updateg** 更新并重启(仅 npm 全局安装可用)",
|
|
140
141
|
].join("\n");
|
|
141
142
|
return JSON.stringify({
|
package/src/feishu-api.ts
CHANGED
|
@@ -307,20 +307,30 @@ const AVATAR_SOURCES: Record<string, string> = {
|
|
|
307
307
|
busy: resolvePath(AVATAR_DIR, "status_busy.png"),
|
|
308
308
|
idle: resolvePath(AVATAR_DIR, "status_idle.png"),
|
|
309
309
|
};
|
|
310
|
-
const AVATAR_BADGES: Record<string, string> = {
|
|
311
|
-
claude: resolvePath(AVATAR_BADGE_DIR, "badge_claude.png"),
|
|
312
|
-
cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
|
|
313
|
-
codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
|
|
314
|
-
};
|
|
315
|
-
const AVATAR_SIZE = 256;
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
310
|
+
const AVATAR_BADGES: Record<string, string> = {
|
|
311
|
+
claude: resolvePath(AVATAR_BADGE_DIR, "badge_claude.png"),
|
|
312
|
+
cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
|
|
313
|
+
codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
|
|
314
|
+
};
|
|
315
|
+
const AVATAR_SIZE = 256;
|
|
316
|
+
const AVATAR_BADGE_SIZE = 92;
|
|
317
|
+
const AVATAR_BADGE_MARGIN = 10;
|
|
318
|
+
const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-ring-gray-consumed-v1";
|
|
319
|
+
|
|
320
|
+
export interface CodexUsageBalance {
|
|
321
|
+
usedPercent: number;
|
|
322
|
+
remainingPercent: number;
|
|
323
|
+
resetAtEpochSeconds: number | null;
|
|
324
|
+
resetAfterSeconds: number | null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export interface CodexUsageSummary {
|
|
328
|
+
fiveHour: CodexUsageBalance;
|
|
329
|
+
weekly: CodexUsageBalance | null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const avatarKeyCache = new Map<string, string>();
|
|
333
|
+
let avatarKeyCacheLoaded = false;
|
|
324
334
|
|
|
325
335
|
function normalizeAvatarTool(tool: string): string {
|
|
326
336
|
return AVATAR_BADGES[tool] ? tool : "claude";
|
|
@@ -334,16 +344,16 @@ function avatarCombinationPath(tool: string, status: string): string {
|
|
|
334
344
|
return resolvePath(AVATAR_COMBINATIONS_DIR, `avatar_${normalizeAvatarTool(tool)}_${normalizeAvatarStatus(status)}.png`);
|
|
335
345
|
}
|
|
336
346
|
|
|
337
|
-
function avatarCacheKey(tool: string, status: string, codexUsage:
|
|
338
|
-
const normalizedTool = normalizeAvatarTool(tool);
|
|
339
|
-
const normalizedStatus = normalizeAvatarStatus(status);
|
|
340
|
-
if (normalizedTool === "codex") {
|
|
341
|
-
return codexUsage
|
|
342
|
-
? `${normalizedTool}:${normalizedStatus}:battery:${codexUsage.remainingPercent}`
|
|
343
|
-
: `${normalizedTool}:${normalizedStatus}:plain`;
|
|
344
|
-
}
|
|
345
|
-
return `${normalizedTool}:${normalizedStatus}`;
|
|
346
|
-
}
|
|
347
|
+
function avatarCacheKey(tool: string, status: string, codexUsage: CodexUsageSummary | null = null): string {
|
|
348
|
+
const normalizedTool = normalizeAvatarTool(tool);
|
|
349
|
+
const normalizedStatus = normalizeAvatarStatus(status);
|
|
350
|
+
if (normalizedTool === "codex") {
|
|
351
|
+
return codexUsage
|
|
352
|
+
? `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:week-battery:${codexUsage.weekly?.remainingPercent}:5h-ring:${codexUsage.fiveHour.remainingPercent}`
|
|
353
|
+
: `${normalizedTool}:${normalizedStatus}:plain`;
|
|
354
|
+
}
|
|
355
|
+
return `${normalizedTool}:${normalizedStatus}`;
|
|
356
|
+
}
|
|
347
357
|
|
|
348
358
|
async function loadAvatarKeyCache(): Promise<void> {
|
|
349
359
|
if (avatarKeyCacheLoaded) return;
|
|
@@ -368,12 +378,37 @@ async function persistAvatarKeyCache(): Promise<void> {
|
|
|
368
378
|
);
|
|
369
379
|
}
|
|
370
380
|
|
|
371
|
-
function clampPercent(value: number): number {
|
|
372
|
-
if (!Number.isFinite(value)) return 0;
|
|
373
|
-
return Math.max(0, Math.min(100, Math.round(value)));
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
|
|
381
|
+
function clampPercent(value: number): number {
|
|
382
|
+
if (!Number.isFinite(value)) return 0;
|
|
383
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function usageBalanceFromWindow(raw: Record<string, unknown>, fieldName: string): CodexUsageBalance {
|
|
387
|
+
const value = raw.used_percent;
|
|
388
|
+
const usedPercent = Number(value);
|
|
389
|
+
if (!Number.isFinite(usedPercent)) throw new Error(`missing ${fieldName}.used_percent`);
|
|
390
|
+
const used = clampPercent(usedPercent);
|
|
391
|
+
const resetAt = Number(raw.reset_at);
|
|
392
|
+
const resetAfter = Number(raw.reset_after_seconds);
|
|
393
|
+
return {
|
|
394
|
+
usedPercent: used,
|
|
395
|
+
remainingPercent: clampPercent(100 - used),
|
|
396
|
+
resetAtEpochSeconds: Number.isFinite(resetAt) ? resetAt : null,
|
|
397
|
+
resetAfterSeconds: Number.isFinite(resetAfter) ? resetAfter : null,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function parseOptionalUsageWindow(rateLimit: Record<string, unknown>, keys: string[]): CodexUsageBalance | null {
|
|
402
|
+
for (const key of keys) {
|
|
403
|
+
const raw = rateLimit[key];
|
|
404
|
+
if (!raw || typeof raw !== "object") continue;
|
|
405
|
+
if ((raw as { used_percent?: unknown }).used_percent === undefined) continue;
|
|
406
|
+
return usageBalanceFromWindow(raw as Record<string, unknown>, `rate_limit.${key}`);
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function getCodexAccessToken(): Promise<string | null> {
|
|
377
412
|
try {
|
|
378
413
|
const raw = await readFile(CODEX_AUTH_FILE, "utf-8");
|
|
379
414
|
const parsed = JSON.parse(raw) as { tokens?: { access_token?: unknown } };
|
|
@@ -381,36 +416,49 @@ async function getCodexAccessToken(): Promise<string | null> {
|
|
|
381
416
|
return typeof token === "string" && token.trim() ? token : null;
|
|
382
417
|
} catch {
|
|
383
418
|
return null;
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
async function
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
|
|
423
|
+
const token = await getCodexAccessToken();
|
|
424
|
+
if (!token) throw new Error("missing ~/.codex/auth.json access token");
|
|
425
|
+
|
|
426
|
+
const resp = await fetch(CODEX_USAGE_URL, {
|
|
427
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
428
|
+
});
|
|
429
|
+
const text = await resp.text();
|
|
430
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
|
|
431
|
+
|
|
432
|
+
const data = JSON.parse(text) as {
|
|
433
|
+
rate_limit?: Record<string, unknown>;
|
|
434
|
+
};
|
|
435
|
+
const rateLimit = data.rate_limit;
|
|
436
|
+
if (!rateLimit || typeof rateLimit !== "object") throw new Error("missing rate_limit");
|
|
437
|
+
|
|
438
|
+
const fiveHour = parseOptionalUsageWindow(rateLimit, ["primary_window"]);
|
|
439
|
+
if (!fiveHour) throw new Error("missing rate_limit.primary_window.used_percent");
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
fiveHour,
|
|
443
|
+
weekly: parseOptionalUsageWindow(rateLimit, [
|
|
444
|
+
"secondary_window",
|
|
445
|
+
"weekly_window",
|
|
446
|
+
"week_window",
|
|
447
|
+
"long_window",
|
|
448
|
+
]),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function fetchCodexAvatarUsage(): Promise<CodexUsageSummary | null> {
|
|
453
|
+
try {
|
|
454
|
+
const summary = await getCodexUsageSummary();
|
|
455
|
+
if (!summary.weekly) throw new Error("missing weekly usage window");
|
|
456
|
+
return summary;
|
|
457
|
+
} catch (err) {
|
|
458
|
+
console.warn(`[${ts()}] [AVATAR] Codex usage unavailable, using plain avatar: ${(err as Error).message}`);
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
414
462
|
|
|
415
463
|
function codexUsagePalette(remainingPercent: number): { start: string; end: string; glow: string } {
|
|
416
464
|
if (remainingPercent <= 25) return { start: "#ef4444", end: "#fb923c", glow: "#fed7aa" };
|
|
@@ -418,7 +466,7 @@ function codexUsagePalette(remainingPercent: number): { start: string; end: stri
|
|
|
418
466
|
return { start: "#16a34a", end: "#34d399", glow: "#bbf7d0" };
|
|
419
467
|
}
|
|
420
468
|
|
|
421
|
-
function buildCodexUsageBatterySvg(remainingPercent: number): Buffer {
|
|
469
|
+
function buildCodexUsageBatterySvg(remainingPercent: number): Buffer {
|
|
422
470
|
const remaining = clampPercent(remainingPercent);
|
|
423
471
|
const label = `${remaining}%`;
|
|
424
472
|
const palette = codexUsagePalette(remaining);
|
|
@@ -462,23 +510,90 @@ function buildCodexUsageBatterySvg(remainingPercent: number): Buffer {
|
|
|
462
510
|
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="15" fill="none" stroke="#f8fafc" stroke-opacity="0.82" stroke-width="3.2"/>
|
|
463
511
|
<text x="${bodyX + bodyW / 2}" y="${bodyY + 32}" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="27" font-weight="900" letter-spacing="0" stroke="#0b1220" stroke-width="5" paint-order="stroke" stroke-linejoin="round" fill="#ffffff">${label}</text>
|
|
464
512
|
</g>
|
|
465
|
-
</svg>`);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
const
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
513
|
+
</svg>`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function buildCodexUsageRingSvg(remainingPercent: number): Buffer {
|
|
517
|
+
const remaining = clampPercent(remainingPercent);
|
|
518
|
+
const palette = codexUsagePalette(remaining);
|
|
519
|
+
const cx = 128;
|
|
520
|
+
const cy = 128;
|
|
521
|
+
const r = 118;
|
|
522
|
+
const strokeWidth = 13;
|
|
523
|
+
const used = clampPercent(100 - remaining);
|
|
524
|
+
const polar = (angleDegrees: number) => {
|
|
525
|
+
const angle = (angleDegrees * Math.PI) / 180;
|
|
526
|
+
return {
|
|
527
|
+
x: cx + r * Math.cos(angle),
|
|
528
|
+
y: cy + r * Math.sin(angle),
|
|
529
|
+
};
|
|
530
|
+
};
|
|
531
|
+
const startAngle = -90 + (used / 100) * 360;
|
|
532
|
+
const sweepAngle = (remaining / 100) * 360;
|
|
533
|
+
const start = polar(startAngle);
|
|
534
|
+
const end = polar(startAngle + Math.min(sweepAngle, 359.99));
|
|
535
|
+
const largeArcFlag = sweepAngle > 180 ? 1 : 0;
|
|
536
|
+
const progressPath = remaining >= 100
|
|
537
|
+
? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="url(#ring)" stroke-width="${strokeWidth}" stroke-linecap="round" filter="url(#ringShadow)"/>`
|
|
538
|
+
: remaining <= 0
|
|
539
|
+
? ""
|
|
540
|
+
: `<path d="M ${start.x.toFixed(3)} ${start.y.toFixed(3)} A ${r} ${r} 0 ${largeArcFlag} 1 ${end.x.toFixed(3)} ${end.y.toFixed(3)}" fill="none" stroke="url(#ring)" stroke-width="${strokeWidth}" stroke-linecap="round" filter="url(#ringShadow)"/>`;
|
|
541
|
+
|
|
542
|
+
return Buffer.from(`
|
|
543
|
+
<svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
|
|
544
|
+
<defs>
|
|
545
|
+
<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
|
|
546
|
+
<stop offset="0" stop-color="${palette.start}"/>
|
|
547
|
+
<stop offset="1" stop-color="${palette.end}"/>
|
|
548
|
+
</linearGradient>
|
|
549
|
+
<filter id="ringShadow" x="-10%" y="-10%" width="120%" height="120%">
|
|
550
|
+
<feDropShadow dx="0" dy="2" stdDeviation="2.4" flood-color="#0f172a" flood-opacity="0.25"/>
|
|
551
|
+
</filter>
|
|
552
|
+
</defs>
|
|
553
|
+
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#cbd5e1" stroke-width="${strokeWidth}"/>
|
|
554
|
+
${progressPath}
|
|
555
|
+
</svg>`);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOptions> {
|
|
559
|
+
const badge = await sharp(AVATAR_BADGES[tool])
|
|
560
|
+
.resize(AVATAR_BADGE_SIZE, AVATAR_BADGE_SIZE, {
|
|
561
|
+
fit: "contain",
|
|
562
|
+
kernel: sharp.kernel.lanczos3,
|
|
563
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
564
|
+
})
|
|
565
|
+
.png()
|
|
566
|
+
.toBuffer();
|
|
567
|
+
return {
|
|
568
|
+
input: badge,
|
|
569
|
+
left: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
|
|
570
|
+
top: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async function renderAvatar(tool: string, status: string, codexUsage: CodexUsageSummary | null = null): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
|
575
|
+
const normalizedTool = normalizeAvatarTool(tool);
|
|
576
|
+
const normalizedStatus = normalizeAvatarStatus(status);
|
|
577
|
+
const composites: sharp.OverlayOptions[] = [];
|
|
578
|
+
|
|
579
|
+
const useDynamicCodexAvatar = normalizedTool === "codex" && codexUsage?.weekly;
|
|
580
|
+
const basePath = useDynamicCodexAvatar
|
|
581
|
+
? AVATAR_SOURCES[normalizedStatus]
|
|
582
|
+
: avatarCombinationPath(normalizedTool, normalizedStatus);
|
|
583
|
+
|
|
584
|
+
if (useDynamicCodexAvatar) {
|
|
585
|
+
composites.push(
|
|
586
|
+
{ input: buildCodexUsageRingSvg(codexUsage.fiveHour.remainingPercent), left: 0, top: 0 },
|
|
587
|
+
{ input: buildCodexUsageBatterySvg(codexUsage.weekly.remainingPercent), left: 0, top: 0 },
|
|
588
|
+
await buildAgentBadgeOverlay(normalizedTool),
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
let pipeline = sharp(await readFile(basePath))
|
|
593
|
+
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" });
|
|
594
|
+
if (composites.length > 0) {
|
|
595
|
+
pipeline = pipeline.composite(composites);
|
|
596
|
+
}
|
|
482
597
|
|
|
483
598
|
const jpeg = await pipeline
|
|
484
599
|
.flatten({ background: "#ffffff" })
|
|
@@ -486,16 +601,16 @@ async function renderAvatar(tool: string, status: string, codexUsage: CodexUsage
|
|
|
486
601
|
.jpeg({ quality: 95, progressive: false })
|
|
487
602
|
.toBuffer();
|
|
488
603
|
|
|
489
|
-
return {
|
|
490
|
-
buffer: jpeg,
|
|
491
|
-
contentType: "image/jpeg",
|
|
492
|
-
filename: codexUsage
|
|
493
|
-
? `avatar_${normalizedTool}_${normalizedStatus}
|
|
494
|
-
: `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
async function uploadImage(token: string, tool: string, status: string, codexUsage:
|
|
604
|
+
return {
|
|
605
|
+
buffer: jpeg,
|
|
606
|
+
contentType: "image/jpeg",
|
|
607
|
+
filename: codexUsage?.weekly
|
|
608
|
+
? `avatar_${normalizedTool}_${normalizedStatus}_week_${codexUsage.weekly.remainingPercent}_5h_${codexUsage.fiveHour.remainingPercent}.jpg`
|
|
609
|
+
: `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function uploadImage(token: string, tool: string, status: string, codexUsage: CodexUsageSummary | null = null): Promise<string> {
|
|
499
614
|
const image = await renderAvatar(tool, status, codexUsage);
|
|
500
615
|
const blob = new Blob([new Uint8Array(image.buffer)], { type: image.contentType });
|
|
501
616
|
const form = new FormData();
|
|
@@ -518,10 +633,10 @@ async function uploadImage(token: string, tool: string, status: string, codexUsa
|
|
|
518
633
|
}
|
|
519
634
|
|
|
520
635
|
async function getOrUploadAvatarKey(token: string, tool: string, status: string): Promise<string> {
|
|
521
|
-
await loadAvatarKeyCache();
|
|
522
|
-
const normalizedTool = normalizeAvatarTool(tool);
|
|
523
|
-
const normalizedStatus = normalizeAvatarStatus(status);
|
|
524
|
-
const codexUsage = normalizedTool === "codex" ? await
|
|
636
|
+
await loadAvatarKeyCache();
|
|
637
|
+
const normalizedTool = normalizeAvatarTool(tool);
|
|
638
|
+
const normalizedStatus = normalizeAvatarStatus(status);
|
|
639
|
+
const codexUsage = normalizedTool === "codex" ? await fetchCodexAvatarUsage() : null;
|
|
525
640
|
const keyName = avatarCacheKey(normalizedTool, normalizedStatus, codexUsage);
|
|
526
641
|
const cached = avatarKeyCache.get(keyName);
|
|
527
642
|
if (cached) return cached;
|
package/src/feishu-platform.ts
CHANGED
|
@@ -29,8 +29,9 @@ export interface FeishuPlatform {
|
|
|
29
29
|
updateChatInfo: typeof realApi.updateChatInfo;
|
|
30
30
|
getChatInfo: typeof realApi.getChatInfo;
|
|
31
31
|
disbandChat: typeof realApi.disbandChat;
|
|
32
|
-
setChatAvatar: typeof realApi.setChatAvatar;
|
|
33
|
-
|
|
32
|
+
setChatAvatar: typeof realApi.setChatAvatar;
|
|
33
|
+
getCodexUsageSummary: typeof realApi.getCodexUsageSummary;
|
|
34
|
+
getOrDownloadImage: typeof realApi.getOrDownloadImage;
|
|
34
35
|
verifyAllPermissions: typeof realApi.verifyAllPermissions;
|
|
35
36
|
reportPermissionResults: typeof realApi.reportPermissionResults;
|
|
36
37
|
extractSessionInfo: typeof realApi.extractSessionInfo;
|
|
@@ -108,13 +109,17 @@ export function disbandChat(...args: Parameters<typeof realApi.disbandChat>): Re
|
|
|
108
109
|
return _impl.disbandChat(...args);
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
export function setChatAvatar(...args: Parameters<typeof realApi.setChatAvatar>): ReturnType<typeof realApi.setChatAvatar> {
|
|
112
|
-
return _impl.setChatAvatar(...args);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function
|
|
116
|
-
return _impl.
|
|
117
|
-
}
|
|
112
|
+
export function setChatAvatar(...args: Parameters<typeof realApi.setChatAvatar>): ReturnType<typeof realApi.setChatAvatar> {
|
|
113
|
+
return _impl.setChatAvatar(...args);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getCodexUsageSummary(...args: Parameters<typeof realApi.getCodexUsageSummary>): ReturnType<typeof realApi.getCodexUsageSummary> {
|
|
117
|
+
return _impl.getCodexUsageSummary(...args);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getOrDownloadImage(...args: Parameters<typeof realApi.getOrDownloadImage>): ReturnType<typeof realApi.getOrDownloadImage> {
|
|
121
|
+
return _impl.getOrDownloadImage(...args);
|
|
122
|
+
}
|
|
118
123
|
|
|
119
124
|
export function verifyAllPermissions(...args: Parameters<typeof realApi.verifyAllPermissions>): ReturnType<typeof realApi.verifyAllPermissions> {
|
|
120
125
|
return _impl.verifyAllPermissions(...args);
|
|
@@ -146,4 +151,4 @@ export function sendRestartCard(...args: Parameters<typeof realApi.sendRestartCa
|
|
|
146
151
|
|
|
147
152
|
export function getMergeForwardMessages(...args: Parameters<typeof realApi.getMergeForwardMessages>): ReturnType<typeof realApi.getMergeForwardMessages> {
|
|
148
153
|
return _impl.getMergeForwardMessages(...args);
|
|
149
|
-
}
|
|
154
|
+
}
|
package/src/orchestrator.ts
CHANGED
|
@@ -78,9 +78,10 @@ import {
|
|
|
78
78
|
enqueueMessage,
|
|
79
79
|
cancelQueuedMessage,
|
|
80
80
|
} from "./session-chat-binding.ts";
|
|
81
|
-
import { getTenantAccessToken, sendPostMessage } from "./feishu-platform.ts";
|
|
82
|
-
export { type PlatformAdapter } from "./platform-adapter.ts";
|
|
83
|
-
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
81
|
+
import { getCodexUsageSummary, getTenantAccessToken, sendPostMessage } from "./feishu-platform.ts";
|
|
82
|
+
export { type PlatformAdapter } from "./platform-adapter.ts";
|
|
83
|
+
import type { PlatformAdapter } from "./platform-adapter.ts";
|
|
84
|
+
import type { CodexUsageSummary } from "./feishu-api.ts";
|
|
84
85
|
|
|
85
86
|
// ---------------------------------------------------------------------------
|
|
86
87
|
// 辅助函数
|
|
@@ -96,7 +97,7 @@ export function sessionChatName(left: string, cwd: string): string {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
/** 模型模糊匹配:精确匹配优先,否则找子串匹配(模型名越短越优先) */
|
|
99
|
-
function findModelMatch(input: string, models: string[]): string | null {
|
|
100
|
+
function findModelMatch(input: string, models: string[]): string | null {
|
|
100
101
|
if (models.length === 0) return null;
|
|
101
102
|
const inputLower = input.toLowerCase();
|
|
102
103
|
// 1) 精确匹配(忽略大小写)
|
|
@@ -108,11 +109,72 @@ function findModelMatch(input: string, models: string[]): string | null {
|
|
|
108
109
|
.filter(m => m.toLowerCase().includes(inputLower))
|
|
109
110
|
.sort((a, b) => a.length - b.length);
|
|
110
111
|
return candidates[0] ?? null;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatCodexUsageSummary(usage: CodexUsageSummary): string {
|
|
115
|
+
const progressBar = (usedPercent: number) => {
|
|
116
|
+
const width = 20;
|
|
117
|
+
const usedBlocks = Math.max(0, Math.min(width, Math.round((usedPercent / 100) * width)));
|
|
118
|
+
return `[${"█".repeat(usedBlocks)}${"░".repeat(width - usedBlocks)}]`;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const formatDuration = (seconds: number | null) => {
|
|
122
|
+
if (seconds === null) return "";
|
|
123
|
+
if (seconds <= 0) return "(已到重置时间)";
|
|
124
|
+
const totalMinutes = Math.max(1, Math.floor(seconds / 60));
|
|
125
|
+
const days = Math.floor(totalMinutes / 1440);
|
|
126
|
+
const hours = Math.floor((totalMinutes % 1440) / 60);
|
|
127
|
+
const minutes = totalMinutes % 60;
|
|
128
|
+
const parts: string[] = [];
|
|
129
|
+
if (days > 0) parts.push(`${days}天`);
|
|
130
|
+
if (hours > 0) parts.push(`${hours}小时`);
|
|
131
|
+
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}分钟`);
|
|
132
|
+
return `(约 ${parts.join("")}后)`;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const formatResetTime = (balance: CodexUsageSummary["fiveHour"]) => {
|
|
136
|
+
if (balance.resetAtEpochSeconds === null) return "暂无数据";
|
|
137
|
+
const date = new Date(balance.resetAtEpochSeconds * 1000);
|
|
138
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
139
|
+
const absolute = [
|
|
140
|
+
date.getFullYear(),
|
|
141
|
+
"-",
|
|
142
|
+
pad(date.getMonth() + 1),
|
|
143
|
+
"-",
|
|
144
|
+
pad(date.getDate()),
|
|
145
|
+
" ",
|
|
146
|
+
pad(date.getHours()),
|
|
147
|
+
":",
|
|
148
|
+
pad(date.getMinutes()),
|
|
149
|
+
].join("");
|
|
150
|
+
return `${absolute}${formatDuration(balance.resetAfterSeconds)}`;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const formatWindow = (label: string, balance: CodexUsageSummary["fiveHour"] | null) => {
|
|
154
|
+
if (!balance) return `**${label}:** 暂无数据`;
|
|
155
|
+
return [
|
|
156
|
+
`**${label}:** 已用 ${balance.usedPercent}%,剩余 ${balance.remainingPercent}%,重置: ${formatResetTime(balance)}`,
|
|
157
|
+
progressBar(balance.usedPercent),
|
|
158
|
+
].join("\n");
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return [
|
|
162
|
+
"Codex 用量:",
|
|
163
|
+
"",
|
|
164
|
+
formatWindow("5h", usage.fiveHour),
|
|
165
|
+
formatWindow("周", usage.weekly),
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function codexUsageHelpLine(tool: string): string {
|
|
170
|
+
return tool === "codex"
|
|
171
|
+
? "\n发送 **/usage** 查看 Codex 5h 和周用量。"
|
|
172
|
+
: "";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isUntitledSessionChatName(name: string): boolean {
|
|
176
|
+
return name === "新会话" || name.startsWith("新会话-");
|
|
177
|
+
}
|
|
116
178
|
|
|
117
179
|
function shouldSendWechatProcessingAck(
|
|
118
180
|
platform: PlatformAdapter,
|
|
@@ -292,7 +354,7 @@ export async function handleCommand(
|
|
|
292
354
|
return;
|
|
293
355
|
}
|
|
294
356
|
|
|
295
|
-
if (textLower === "/updateg") {
|
|
357
|
+
if (textLower === "/updateg") {
|
|
296
358
|
logTrace(tid, "BRANCH", { cmd: "/updateg" });
|
|
297
359
|
const isGlobal = isRunningFromGlobalNpm();
|
|
298
360
|
appendStartupTrace("updateg: command received", { isGlobal, chatId });
|
|
@@ -306,10 +368,32 @@ export async function handleCommand(
|
|
|
306
368
|
appendStartupTrace("updateg: sync update begin", { fromPid: process.pid });
|
|
307
369
|
syncUpdateAndRestart();
|
|
308
370
|
setTimeout(() => process.exit(0), 2000);
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (textLower === "/
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (textLower === "/usage") {
|
|
375
|
+
logTrace(tid, "BRANCH", { cmd: "/usage", tool: "codex" });
|
|
376
|
+
try {
|
|
377
|
+
const content = formatCodexUsageSummary(await getCodexUsageSummary());
|
|
378
|
+
if (platform.kind === "wechat") {
|
|
379
|
+
await platform.sendText(chatId, content).catch(() => {});
|
|
380
|
+
} else {
|
|
381
|
+
await platform.sendCard(chatId, "Codex Usage", content, "blue");
|
|
382
|
+
}
|
|
383
|
+
logTrace(tid, "DONE", { outcome: "usage" });
|
|
384
|
+
} catch (err) {
|
|
385
|
+
const message = `无法获取 Codex 用量:${(err as Error).message}`;
|
|
386
|
+
if (platform.kind === "wechat") {
|
|
387
|
+
await platform.sendText(chatId, message).catch(() => {});
|
|
388
|
+
} else {
|
|
389
|
+
await platform.sendCard(chatId, "Codex Usage", message, "red");
|
|
390
|
+
}
|
|
391
|
+
logTrace(tid, "DONE", { outcome: "usage_fail", error: (err as Error).message });
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (textLower === "/cd" || textLower.startsWith("/cd ")) {
|
|
313
397
|
logTrace(tid, "BRANCH", {
|
|
314
398
|
cmd: "/cd",
|
|
315
399
|
arg: text.slice(3).trim() || "(none)",
|
|
@@ -528,12 +612,13 @@ export async function handleCommand(
|
|
|
528
612
|
`**工作目录:** \`${cwd}\`\n\n` +
|
|
529
613
|
`直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
|
|
530
614
|
`发送 **/cd** 切换新建会话的默认目录。\n` +
|
|
531
|
-
`发送 **/model** 查看或切换当前会话的模型。\n` +
|
|
532
|
-
`发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
|
|
533
|
-
`发送 **/sessions** 查看所有会话状态。\n` +
|
|
534
|
-
`发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5
|
|
535
|
-
|
|
536
|
-
|
|
615
|
+
`发送 **/model** 查看或切换当前会话的模型。\n` +
|
|
616
|
+
`发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
|
|
617
|
+
`发送 **/sessions** 查看所有会话状态。\n` +
|
|
618
|
+
`发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。` +
|
|
619
|
+
codexUsageHelpLine(tool),
|
|
620
|
+
"green",
|
|
621
|
+
);
|
|
537
622
|
console.log(
|
|
538
623
|
`[${ts()}] [NEW] P2P session created: ${sessionId} (${toolLabel})`,
|
|
539
624
|
);
|
|
@@ -614,12 +699,13 @@ export async function handleCommand(
|
|
|
614
699
|
`**工作目录:** \`${cwd}\`\n\n` +
|
|
615
700
|
`直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
|
|
616
701
|
`发送 **/cd** 切换新建会话的默认目录。\n` +
|
|
617
|
-
`发送 **/model** 查看或切换当前会话的模型。\n` +
|
|
618
|
-
`发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
|
|
619
|
-
`发送 **/sessions** 查看所有会话状态。\n` +
|
|
620
|
-
`发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5
|
|
621
|
-
|
|
622
|
-
|
|
702
|
+
`发送 **/model** 查看或切换当前会话的模型。\n` +
|
|
703
|
+
`发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
|
|
704
|
+
`发送 **/sessions** 查看所有会话状态。\n` +
|
|
705
|
+
`发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。` +
|
|
706
|
+
codexUsageHelpLine(tool),
|
|
707
|
+
"green",
|
|
708
|
+
);
|
|
623
709
|
|
|
624
710
|
console.log(`[${ts()}] [STEP 4/4] Replied to new group → OK`);
|
|
625
711
|
logTrace(tid, "DONE", {
|
package/src/sim-platform.ts
CHANGED
|
@@ -123,12 +123,19 @@ export const SimulatedPlatform: FeishuPlatform = {
|
|
|
123
123
|
},
|
|
124
124
|
|
|
125
125
|
// ---- 头像 ----
|
|
126
|
-
async setChatAvatar(_token, _chatId, _tool, _status) {
|
|
127
|
-
// 模拟模式不需要头像
|
|
128
|
-
},
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
126
|
+
async setChatAvatar(_token, _chatId, _tool, _status) {
|
|
127
|
+
// 模拟模式不需要头像
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async getCodexUsageSummary() {
|
|
131
|
+
return {
|
|
132
|
+
fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
133
|
+
weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// ---- 图片下载 ----
|
|
138
|
+
async getOrDownloadImage(_token, _messageId, fileKey) {
|
|
132
139
|
return join(SIM_DIR, "images", fileKey);
|
|
133
140
|
},
|
|
134
141
|
|
|
@@ -161,4 +168,4 @@ export const SimulatedPlatform: FeishuPlatform = {
|
|
|
161
168
|
};
|
|
162
169
|
|
|
163
170
|
/** 模拟模式下的默认 chat_id(重新导出以保持向后兼容) */
|
|
164
|
-
export { SIM_DEFAULT_CHAT_ID } from "./sim-store.ts";
|
|
171
|
+
export { SIM_DEFAULT_CHAT_ID } from "./sim-store.ts";
|
package/src/web-ui.ts
CHANGED
|
@@ -480,7 +480,7 @@ async function handleForgetIlink(_req: IncomingMessage, res: ServerResponse): Pr
|
|
|
480
480
|
// HTML page (embedded template)
|
|
481
481
|
// ---------------------------------------------------------------------------
|
|
482
482
|
|
|
483
|
-
const PAGE_HTML = `<!DOCTYPE html>
|
|
483
|
+
export const PAGE_HTML = `<!DOCTYPE html>
|
|
484
484
|
<html lang="zh-CN">
|
|
485
485
|
<head>
|
|
486
486
|
<meta charset="UTF-8">
|
|
@@ -1539,9 +1539,11 @@ function editSection(section) {
|
|
|
1539
1539
|
html += '<option value="feishu"' + (ptVal === 'feishu' ? ' selected' : '') + '>飞书 (open.feishu.cn)</option>';
|
|
1540
1540
|
html += '<option value="lark"' + (ptVal === 'lark' ? ' selected' : '') + '>Lark (open.larksuite.com)</option>';
|
|
1541
1541
|
html += '</select></div>';
|
|
1542
|
-
}
|
|
1543
|
-
document.getElementById('edit-modal-fields').innerHTML = html;
|
|
1544
|
-
|
|
1542
|
+
}
|
|
1543
|
+
document.getElementById('edit-modal-fields').innerHTML = html;
|
|
1544
|
+
document.getElementById('edit-modal').classList.remove('hidden');
|
|
1545
|
+
document.getElementById('edit-overlay').classList.remove('hidden');
|
|
1546
|
+
}
|
|
1545
1547
|
|
|
1546
1548
|
function closeEditModal() {
|
|
1547
1549
|
document.getElementById('edit-modal').classList.add('hidden');
|