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 CHANGED
@@ -308,8 +308,9 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
308
308
  | `/stop` | 停止当前回复 |
309
309
  | `/state` | 查看当前会话状态 |
310
310
  | `/cd` | 查看或设置当前会话工作目录 |
311
- | `/sessions` | 查看所有会话状态 |
312
- | `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.173",
3
+ "version": "0.2.174",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -61,20 +61,47 @@ describe("Codex avatar usage battery", () => {
61
61
  vi.restoreAllMocks();
62
62
  });
63
63
 
64
- it("adds the 5h remaining percent to Codex avatar uploads when usage lookup succeeds", async () => {
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: { primary_window: { used_percent: 37 } },
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(["avatar_codex_busy_usage_63.jpg"]);
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
- getOrDownloadImage: async () => "/tmp/img.png",
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
- } finally {
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
- _setAdapterForToolForTest("claude", mockAdapter());
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
- "发送 **/restart** 重启 ChatCCC 进程",
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
- interface CodexUsageBalance {
318
- usedPercent: number;
319
- remainingPercent: number;
320
- }
321
-
322
- const avatarKeyCache = new Map<string, string>();
323
- let avatarKeyCacheLoaded = false;
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: CodexUsageBalance | null = null): string {
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
- async function getCodexAccessToken(): Promise<string | null> {
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 fetchCodexUsageBalance(): Promise<CodexUsageBalance | null> {
388
- try {
389
- const token = await getCodexAccessToken();
390
- if (!token) throw new Error("missing ~/.codex/auth.json access token");
391
-
392
- const resp = await fetch(CODEX_USAGE_URL, {
393
- headers: { Authorization: `Bearer ${token}` },
394
- });
395
- const text = await resp.text();
396
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
397
-
398
- const data = JSON.parse(text) as {
399
- rate_limit?: { primary_window?: { used_percent?: unknown } };
400
- };
401
- const usedPercent = Number(data.rate_limit?.primary_window?.used_percent);
402
- if (!Number.isFinite(usedPercent)) throw new Error("missing rate_limit.primary_window.used_percent");
403
-
404
- const used = clampPercent(usedPercent);
405
- return {
406
- usedPercent: used,
407
- remainingPercent: clampPercent(100 - used),
408
- };
409
- } catch (err) {
410
- console.warn(`[${ts()}] [AVATAR] Codex usage unavailable, using plain avatar: ${(err as Error).message}`);
411
- return null;
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
- async function renderAvatar(tool: string, status: string, codexUsage: CodexUsageBalance | null = null): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
469
- const normalizedTool = normalizeAvatarTool(tool);
470
- const normalizedStatus = normalizeAvatarStatus(status);
471
- const composites: sharp.OverlayOptions[] = [];
472
-
473
- if (normalizedTool === "codex" && codexUsage) {
474
- composites.push({ input: buildCodexUsageBatterySvg(codexUsage.remainingPercent), left: 0, top: 0 });
475
- }
476
-
477
- let pipeline = sharp(await readFile(avatarCombinationPath(normalizedTool, normalizedStatus)))
478
- .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" });
479
- if (composites.length > 0) {
480
- pipeline = pipeline.composite(composites);
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}_usage_${codexUsage.remainingPercent}.jpg`
494
- : `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
495
- };
496
- }
497
-
498
- async function uploadImage(token: string, tool: string, status: string, codexUsage: CodexUsageBalance | null = null): Promise<string> {
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 fetchCodexUsageBalance() : null;
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;
@@ -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
- getOrDownloadImage: typeof realApi.getOrDownloadImage;
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 getOrDownloadImage(...args: Parameters<typeof realApi.getOrDownloadImage>): ReturnType<typeof realApi.getOrDownloadImage> {
116
- return _impl.getOrDownloadImage(...args);
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
+ }
@@ -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 isUntitledSessionChatName(name: string): boolean {
114
- return name === "新会话" || name.startsWith("新会话-");
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 === "/cd" || textLower.startsWith("/cd ")) {
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
- "green",
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
- "green",
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", {
@@ -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
- async getOrDownloadImage(_token, _messageId, fileKey) {
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');