@zxh126/pi-qqbot 1.3.0 → 1.3.2
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 +5 -4
- package/constants.ts +2 -0
- package/docs/README.zh-CN.md +4 -4
- package/package.json +1 -1
- package/slash.ts +143 -9
package/README.md
CHANGED
|
@@ -117,12 +117,13 @@ Some extension commands never print into the conversation — they call `ctx.ui.
|
|
|
117
117
|
|
|
118
118
|
| Command | What happens |
|
|
119
119
|
|---|---|
|
|
120
|
-
| `/usage [range]`, `/cost [range]` | re-dispatched as `/qq usage <range>`: the bridge runs the original `/usage` command, then reads the fresh `customType: "usage-report"` entry from the session and renders it as text (requests, tokens, cache ratio, cost, per-model). **Usage unchanged** — keep sending `/usage today`. |
|
|
120
|
+
| `/usage [range]`, `/cost [range]` | re-dispatched as `/qq usage <range>`: the bridge runs the original `/usage` command, then reads the **fresh** `customType: "usage-report"` entry — first from `sessionManager.getEntries()`, and if that view has not picked up the append yet, straight from the tail of the session file — and renders it as text (requests, tokens, cache ratio, cost, per-model). **Usage unchanged** — keep sending `/usage today`. |
|
|
121
|
+
| `/usage rate <value>`, `/usage rate auto` | really executed (they write `~/.pi/agent/pi-usage-report.json` / refresh the cached rate); the bridge then reads those files back and reports the resulting rate, so an invalid value shows up as "unchanged" |
|
|
121
122
|
| `/mcp`, `/llama` | answered: they need host-TUI selection dialogs / browser login, so nothing is dispatched |
|
|
122
123
|
|
|
123
|
-
`/usage rate
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
A bare `/usage rate` and `/usage help` are answered locally, without running the command. Coupling with
|
|
125
|
+
[`pi-usage-report`](https://gitee.com/zxh126/pi-usage-report) is limited to the `usage-report` entry type
|
|
126
|
+
plus the two files it keeps in `~/.pi/agent/`.
|
|
126
127
|
|
|
127
128
|
Guards:
|
|
128
129
|
|
package/constants.ts
CHANGED
|
@@ -16,5 +16,7 @@ export const DISPATCH_RETRY_MS = 3000;
|
|
|
16
16
|
export const CANCEL_SETTLE_GRACE_MS = 8000;
|
|
17
17
|
|
|
18
18
|
const PI_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
19
|
+
/** pi 的 agent 目录(其他扩展也往里写东西,如 pi-usage-report) */
|
|
20
|
+
export const PI_AGENT_DIR = PI_DIR;
|
|
19
21
|
export const CONFIG_PATH = () => path.join(PI_DIR, "pi-qqbot.json");
|
|
20
22
|
export const LOCK_PATH = (appId: string) => path.join(PI_DIR, `pi-qqbot-${appId}.lock`);
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -115,12 +115,12 @@ TUI/RPC 前端。在 QQ 里的表现就是「已派发,然后没下文」。
|
|
|
115
115
|
|
|
116
116
|
| 命令 | 现在发生什么 |
|
|
117
117
|
|---|---|
|
|
118
|
-
| `/usage [范围]`、`/cost [范围]` | 改派为 `/qq usage <范围>`:桥接先重跑原 `/usage`
|
|
118
|
+
| `/usage [范围]`、`/cost [范围]` | 改派为 `/qq usage <范围>`:桥接先重跑原 `/usage` 命令,再取**本次新写入**的 `customType: "usage-report"` 条目(先读 `sessionManager.getEntries()` 的增量;发现另一个扩展的 `appendEntry` 还没反映到该视图时,直接读会话文件尾部),渲染成文本回 QQ(请求数/总 token/缓存比/费用/按模型)。**用法不变**,照旧发 `/usage today` |
|
|
119
119
|
| `/mcp`、`/llama` | 直接回执:它们需要主机 TUI 的选择框 / 浏览器登录,不会派发 |
|
|
120
|
+
| `/usage rate <值>`、`/usage rate auto` | 会真的执行(写 `~/.pi/agent/pi-usage-report.json` / 刷新汇率缓存),桥接再读回文件确认新汇率,无效值会体现在“汇率未变”上;只发 `/usage rate` 与 `/usage help` 则本地直接回执,不执行命令 |
|
|
120
121
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
custom entry 类型名。
|
|
122
|
+
桥接与 [`pi-usage-report`](https://gitee.com/zxh126/pi-usage-report) 的耦合只有三处:`usage-report` 这个
|
|
123
|
+
custom entry 类型名,以及它写在 `~/.pi/agent/` 下的两个配置文件。
|
|
124
124
|
|
|
125
125
|
约束:
|
|
126
126
|
|
package/package.json
CHANGED
package/slash.ts
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
import fs from "node:fs";
|
|
27
27
|
import path from "node:path";
|
|
28
28
|
|
|
29
|
+
import { PI_AGENT_DIR } from "./constants";
|
|
30
|
+
|
|
29
31
|
export interface SlashCommandText {
|
|
30
32
|
/** command name without the leading slash, lower-cased (`skill:name` keeps its colon) */
|
|
31
33
|
name: string;
|
|
@@ -380,6 +382,51 @@ export function pickerExpired(picker: PickerState | null): boolean {
|
|
|
380
382
|
/** pi-usage-report 写入 session 的 custom entry 类型(耦合点,约定值) */
|
|
381
383
|
const USAGE_ENTRY_TYPE = "usage-report";
|
|
382
384
|
|
|
385
|
+
/** pi-usage-report 的配置/汇率缓存(读回来确认:命令真的生效了,而不是口头应下) */
|
|
386
|
+
const USAGE_SETTINGS_FILE = "pi-usage-report.json";
|
|
387
|
+
const USAGE_RATE_FILE = "pi-usage-report-rate.json";
|
|
388
|
+
|
|
389
|
+
const USAGE_HELP = [
|
|
390
|
+
"/usage(默认 today)",
|
|
391
|
+
"/usage today | yesterday | week | lastweek | month | lastmonth | all",
|
|
392
|
+
"/usage 7d | 12h | 2w | 3m | 10天 滚动窗口",
|
|
393
|
+
"/usage 2026-09-01..2026-09-30 本地日期区间(含首尾)",
|
|
394
|
+
"/usage 今天 | 昨天 | 本周 | 上周 | 本月 | 上月 | 全部",
|
|
395
|
+
"/usage rate <值> | rate auto 固定 USD→本币汇率,或恢复自动(会真的写配置)",
|
|
396
|
+
].join("\n");
|
|
397
|
+
|
|
398
|
+
function readAgentJson(file: string): any {
|
|
399
|
+
try {
|
|
400
|
+
return JSON.parse(fs.readFileSync(path.join(PI_AGENT_DIR, file), "utf8"));
|
|
401
|
+
} catch {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const CURRENCY_SYMBOLS: Record<string, string> = { CNY: "¥", USD: "$", EUR: "€", JPY: "¥", GBP: "£", HKD: "HK$" };
|
|
407
|
+
|
|
408
|
+
/** 复刻 pi-usage-report 的 readSettings 优先级:fixed(usdRate) > 缓存的发布汇率 > 无 */
|
|
409
|
+
function usageMoney(): { code: string; symbol: string; rate: number | null; source: string } {
|
|
410
|
+
const raw = readAgentJson(USAGE_SETTINGS_FILE) ?? {};
|
|
411
|
+
const code = String(raw.currency ?? "CNY").toUpperCase();
|
|
412
|
+
const symbol = typeof raw.symbol === "string" ? raw.symbol : (CURRENCY_SYMBOLS[code] ?? code);
|
|
413
|
+
const pinned = typeof raw.usdRate === "number" && raw.usdRate > 0 ? raw.usdRate : undefined;
|
|
414
|
+
const fixed = raw.rateMode === "auto" ? undefined : pinned;
|
|
415
|
+
if (fixed !== undefined) return { code, symbol, rate: fixed, source: "fixed,/usage rate 手动固定" };
|
|
416
|
+
const cache = readAgentJson(USAGE_RATE_FILE);
|
|
417
|
+
if (cache && cache.code === code && typeof cache.rate === "number" && cache.rate > 0) {
|
|
418
|
+
const ageMinutes = Math.max(0, Math.round((Date.now() - Number(cache.fetchedAt ?? 0)) / 60000));
|
|
419
|
+
return { code, symbol, rate: cache.rate, source: `cache,${ageMinutes} 分钟前抓取` };
|
|
420
|
+
}
|
|
421
|
+
return { code, symbol, rate: null, source: "无,可用 /usage rate <值>" };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function moneyText(money: { code: string; rate: number | null; source: string }): string {
|
|
425
|
+
return money.rate === null
|
|
426
|
+
? `USD/${money.code}:无可用汇率(${money.source})`
|
|
427
|
+
: `USD/${money.code} = ${money.rate}(${money.source})`;
|
|
428
|
+
}
|
|
429
|
+
|
|
383
430
|
function sessionEntries(ctx: any): any[] {
|
|
384
431
|
try {
|
|
385
432
|
return ctx?.sessionManager?.getEntries?.() ?? [];
|
|
@@ -388,6 +435,64 @@ function sessionEntries(ctx: any): any[] {
|
|
|
388
435
|
}
|
|
389
436
|
}
|
|
390
437
|
|
|
438
|
+
function sessionFile(ctx: any): string | undefined {
|
|
439
|
+
try {
|
|
440
|
+
return ctx?.sessionManager?.getSessionFile?.() ?? undefined;
|
|
441
|
+
} catch {
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** 兼容三种时间形态:毫秒数字(pi.appendEntry 实际写的就是它)/ ISO 字符串 / Date */
|
|
447
|
+
function entryTime(value: unknown): number {
|
|
448
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
|
|
449
|
+
if (value instanceof Date) return value.getTime();
|
|
450
|
+
if (typeof value === "string" && value) return Date.parse(value);
|
|
451
|
+
return NaN;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* 从会话文件尾部找最近一条 usage-report(generatedAt 不早于 since)。
|
|
456
|
+
*
|
|
457
|
+
* 为什么需要这条路:appendEntry 是**另一个扩展**写的,命令上下文里的
|
|
458
|
+
* `sessionManager.getEntries()` 不一定即时反映(实测 `/usage today` 已经写入了
|
|
459
|
+
* 会话文件,但 getEntries 看不到新增项)。数据库不会骗人,文件会。
|
|
460
|
+
*/
|
|
461
|
+
function readUsageEntryFromFile(file: string | undefined, since: number): any | undefined {
|
|
462
|
+
if (!file) return undefined;
|
|
463
|
+
try {
|
|
464
|
+
const size = fs.statSync(file).size;
|
|
465
|
+
const chunk = Math.min(size, 512 * 1024);
|
|
466
|
+
if (chunk <= 0) return undefined;
|
|
467
|
+
const fd = fs.openSync(file, "r");
|
|
468
|
+
let text = "";
|
|
469
|
+
try {
|
|
470
|
+
const buf = Buffer.alloc(chunk);
|
|
471
|
+
fs.readSync(fd, buf, 0, chunk, size - chunk);
|
|
472
|
+
text = buf.toString("utf8");
|
|
473
|
+
} finally {
|
|
474
|
+
fs.closeSync(fd);
|
|
475
|
+
}
|
|
476
|
+
const lines = text.split("\n");
|
|
477
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
478
|
+
const line = lines[i].trim();
|
|
479
|
+
if (!line.startsWith("{")) continue;
|
|
480
|
+
let entry: any;
|
|
481
|
+
try {
|
|
482
|
+
entry = JSON.parse(line);
|
|
483
|
+
} catch {
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (entry?.type !== "custom" || entry?.customType !== USAGE_ENTRY_TYPE) continue;
|
|
487
|
+
const when = entryTime(entry?.data?.generatedAt ?? entry?.timestamp);
|
|
488
|
+
return Number.isFinite(when) && when >= since ? entry : undefined;
|
|
489
|
+
}
|
|
490
|
+
} catch {
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
|
|
391
496
|
const intText = (value: unknown): string =>
|
|
392
497
|
typeof value === "number" && Number.isFinite(value) ? Math.round(value).toLocaleString("en-US") : "0";
|
|
393
498
|
|
|
@@ -536,19 +641,45 @@ export async function runBridge(
|
|
|
536
641
|
}
|
|
537
642
|
case "usage": {
|
|
538
643
|
// /qq usage [...]:重跑原 /usage 命令,再从 session 里读回它写的 custom entry
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
644
|
+
const rest = arg.slice(first.length).trim();
|
|
645
|
+
|
|
646
|
+
// help:不执行命令(它在 TUI 打文本),直接回本地帮助
|
|
647
|
+
if (first === "help" || first === "?") {
|
|
648
|
+
reply(USAGE_HELP);
|
|
543
649
|
return;
|
|
544
650
|
}
|
|
651
|
+
|
|
545
652
|
const commands = (pi?.getCommands?.() ?? []) as { name: string }[];
|
|
546
|
-
|
|
653
|
+
const hasUsage = commands.some((c) => ["usage", "cost"].includes(String(c.name).toLowerCase()));
|
|
654
|
+
if (!hasUsage) {
|
|
547
655
|
reply("主机上没有 /usage 命令(需要安装 pi-usage-report),无法统计。");
|
|
548
656
|
return;
|
|
549
657
|
}
|
|
658
|
+
|
|
659
|
+
// rate:会写配置/联网刷新汇率,必须真的执行,再读回状态确认(不能只回一句“TUI 可见”)
|
|
660
|
+
if (first === "rate") {
|
|
661
|
+
if (!rest) {
|
|
662
|
+
reply(moneyText(usageMoney()));
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const rateCommand = `/usage rate ${rest}`;
|
|
666
|
+
try {
|
|
667
|
+
await pi.sendUserMessage(rateCommand, { expandPromptTemplates: true });
|
|
668
|
+
} catch (error) {
|
|
669
|
+
reply(`执行 ${rateCommand} 失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const after = usageMoney();
|
|
673
|
+
reply(
|
|
674
|
+
`已执行 ${rateCommand}\n${moneyText(after)}\n(下一步 /usage today 的 ¥/USD 换算就用它;无效值会被忽略)`,
|
|
675
|
+
);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
550
679
|
const command = `/usage${arg ? ` ${arg}` : ""}`;
|
|
680
|
+
const since = Date.now();
|
|
551
681
|
const before = new Set(sessionEntries(ctx).map((entry: any) => entry?.id));
|
|
682
|
+
const file = sessionFile(ctx);
|
|
552
683
|
reply(`正在统计用量(${command})…`);
|
|
553
684
|
try {
|
|
554
685
|
await pi.sendUserMessage(command, { expandPromptTemplates: true });
|
|
@@ -556,11 +687,14 @@ export async function runBridge(
|
|
|
556
687
|
reply(`统计失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
|
|
557
688
|
return;
|
|
558
689
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
690
|
+
// 先看 sessionManager 的增量,再退回读会话文件(appendEntry 可能还没同步到视图)
|
|
691
|
+
const entry =
|
|
692
|
+
[...sessionEntries(ctx)]
|
|
693
|
+
.reverse()
|
|
694
|
+
.find((e: any) => e?.type === "custom" && e?.customType === USAGE_ENTRY_TYPE && !before.has(e?.id)) ??
|
|
695
|
+
readUsageEntryFromFile(file, since);
|
|
562
696
|
if (!entry) {
|
|
563
|
-
reply("
|
|
697
|
+
reply("没拿到用量报告:/usage 已执行但找不到报告数据(可重试一次;若反复出现请把 pi-qqbot.log 尾部发我)。");
|
|
564
698
|
return;
|
|
565
699
|
}
|
|
566
700
|
reply(formatUsage(entry.data));
|