@zxh126/pi-qqbot 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -84,6 +84,7 @@ Any message that starts with `/` is routed before it can become a chat prompt:
84
84
 
85
85
  | # | Kind | Behaviour |
86
86
  |---|---|---|
87
+ | 0 | extension commands whose output only goes to `ctx.ui.*` (`/usage`, `/cost`, `/mcp`, `/llama`) | `/usage` and `/cost` are **bridged** (see below) so the report comes back to QQ; `/mcp` and `/llama` need host-TUI dialogs and are answered with an explanation instead of being silently swallowed |
87
88
  | 1 | pi **extension commands**, prompt templates, `/skill:name` | dispatched to pi with `expandPromptTemplates: true`, so things like `/usage`, `/cost` or your own commands work from QQ |
88
89
  | 2 | pi built-ins this bridge can take over | re-implemented through the extension API (table below) |
89
90
  | 3 | pi built-ins that only exist in the TUI | answered with "run it on the host", never sent to the model |
@@ -108,6 +109,21 @@ Host-only (answered, never forwarded to the model): `/settings`, `/hotkeys`, `/c
108
109
  `/trust`, `/login`, `/logout`, `/scoped-models`, `/llama`, `/import`, `/export`, `/share`, `/bug`,
109
110
  `/copy`, `/privacy`.
110
111
 
112
+ ### TUI-only commands come back to QQ
113
+
114
+ Some extension commands never print into the conversation — they call `ctx.ui.notify()` (and
115
+ `pi.appendEntry()`), which only renders on the host TUI/RPC front end. In QQ that showed up as
116
+ "dispatched, then silence". The bridge closes that hole:
117
+
118
+ | Command | What happens |
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`. |
121
+ | `/mcp`, `/llama` | answered: they need host-TUI selection dialogs / browser login, so nothing is dispatched |
122
+
123
+ `/usage rate ...` and `/usage help` change settings or print help on the TUI; the bridge tells you
124
+ that instead of silently doing nothing. The `usage-report` entry type is the only coupling point with
125
+ [`pi-usage-report`](https://gitee.com/zxh126/pi-usage-report).
126
+
111
127
  Guards:
112
128
 
113
129
  - `/reload`, `/new`, `/compact`, `/quit`, `/tree`, `/fork`, `/clone` and `/resume` refuse to run while a task
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`);
@@ -84,6 +84,7 @@ pi install ./pi-qqbot
84
84
 
85
85
  | # | 类型 | 行为 |
86
86
  |---|---|---|
87
+ | 0 | 输出只走 `ctx.ui.*` 的扩展命令(`/usage`、`/cost`、`/mcp`、`/llama`) | `/usage`、`/cost` 会**桥接**(见下)把报告发回 QQ;`/mcp`、`/llama` 需要主机 TUI 的选择框/登录,直接回执说明,不再静默 |
87
88
  | 1 | pi 的**扩展命令**、prompt 模板、`/skill:name` | 以 `expandPromptTemplates: true` 派发给 pi,所以 `/usage`、`/cost` 或你自己注册的命令在 QQ 里也能用 |
88
89
  | 2 | 本桥接能接管的 pi 内置命令 | 用扩展 API 重新实现(见下表) |
89
90
  | 3 | 只能在 TUI 里跑的 pi 内置命令 | 回执「请在主机执行」,**不会**送进模型 |
@@ -107,6 +108,20 @@ pi install ./pi-qqbot
107
108
  只能在主机执行(回执,不转发给模型):`/settings`、`/hotkeys`、`/changelog`、`/trust`、`/login`、
108
109
  `/logout`、`/scoped-models`、`/llama`、`/import`、`/export`、`/share`、`/bug`、`/copy`、`/privacy`。
109
110
 
111
+ ### 只往 TUI 打字的命令,现在会回到 QQ
112
+
113
+ 有些扩展命令从不往对话里输出,而是调 `ctx.ui.notify()`(配合 `pi.appendEntry()`)——只渲染在主机
114
+ TUI/RPC 前端。在 QQ 里的表现就是「已派发,然后没下文」。桥接把这个洞堵上:
115
+
116
+ | 命令 | 现在发生什么 |
117
+ |---|---|
118
+ | `/usage [范围]`、`/cost [范围]` | 改派为 `/qq usage <范围>`:桥接先重跑原 `/usage` 命令,再从 session 里读出新的 `customType: "usage-report"` 条目,渲染成文本回 QQ(请求数/总 token/缓存比/费用/按模型)。**用法不变**,照旧发 `/usage today` |
119
+ | `/mcp`、`/llama` | 直接回执:它们需要主机 TUI 的选择框 / 浏览器登录,不会派发 |
120
+ | `/usage rate <值>`、`/usage rate auto` | 会真的执行(写 `~/.pi/agent/pi-usage-report.json` / 刷新汇率缓存),桥接再读回该文件确认新汇率;只发 `/usage rate` 与 `/usage help` 则本地直接回执 |
121
+
122
+ 桥接与 [`pi-usage-report`](https://gitee.com/zxh126/pi-usage-report) 的耦合只有两处:`usage-report` 这个
123
+ custom entry 类型名,以及它写在 `~/.pi/agent/` 下的两个配置文件。
124
+
110
125
  约束:
111
126
 
112
127
  - 有任务在跑时,`/reload`、`/new`、`/compact`、`/quit`、`/tree`、`/fork`、`/clone`、`/resume` 会拒绝执行
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zxh126/pi-qqbot",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "description": "Bridge the Pi coding agent into QQ private chats, with remote tool-call approval",
6
6
  "license": "MIT",
@@ -27,7 +27,7 @@
27
27
  ]
28
28
  },
29
29
  "scripts": {
30
- "test": "node test/queue.test.mjs && node test/logger.test.mjs"
30
+ "test": "node test/queue.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs"
31
31
  },
32
32
  "files": [
33
33
  "*.ts",
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;
@@ -130,6 +132,26 @@ export interface RouteDeps {
130
132
 
131
133
  export type DispatchResult = "dispatched" | "queued" | "rejected";
132
134
 
135
+ /**
136
+ * 输出只走 `ctx.ui.*`(TUI / RPC 通道)的扩展命令。
137
+ *
138
+ * 这类命令在 QQ 里发了不会有任何回复:它们的结果是通过 `ctx.ui.notify()` 渲染的,
139
+ * 而本桥接只把 assistant 文本(`message_update.text_delta`)转发到 QQ。
140
+ *
141
+ * - 带 `bridge`:改派 `/qq <bridge> <args>`,由桥接命令重新执行原命令并读 session 里
142
+ * 的 custom entry,自行渲染回 QQ(用户用法不变,仍发 `/usage today`)。
143
+ * - 不带 `bridge`:需要主机 TUI 的交互(选择框/浏览器登录),直接回执说清楚,不能沉默。
144
+ *
145
+ * 必须在 passthrough **之前**判断:这些命令确实在 `pi.getCommands()` 里,
146
+ * 一旦先走 passthrough 就会被派发到黑洞。
147
+ */
148
+ export const UI_ONLY: Readonly<Record<string, { bridge?: string; hint: string }>> = {
149
+ usage: { bridge: "usage", hint: "用量统计会改走 /qq usage 桥接" },
150
+ cost: { bridge: "usage", hint: "用量统计会改走 /qq usage 桥接" },
151
+ mcp: { hint: "MCP 管理需要主机 TUI 的选择框与浏览器登录" },
152
+ llama: { hint: "本地 llama 管理需要主机 TUI" },
153
+ };
154
+
133
155
  export type RouteResult = "dispatched" | "handled" | "text";
134
156
 
135
157
  const allowed = (cfg: CommandConfig, name: string): boolean => {
@@ -144,6 +166,19 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
144
166
  if (!parsed) return "text";
145
167
  const { name } = parsed;
146
168
 
169
+ // 0. 输出只走 TUI 通道的扩展命令:桥接或明确回执(必须早于 passthrough)
170
+ const uiOnly = UI_ONLY[name];
171
+ if (uiOnly && deps.listPiCommands().some((c) => c.name.toLowerCase() === name)) {
172
+ if (uiOnly.bridge) {
173
+ const bridge = `/qq ${uiOnly.bridge}${parsed.args ? ` ${parsed.args}` : ""}`;
174
+ deps.log("[cmd] 桥接 UI-only 命令:", parsed.raw.slice(0, 60), "->", bridge.slice(0, 60));
175
+ deps.dispatch(bridge);
176
+ return "dispatched";
177
+ }
178
+ await deps.reply(`/${name} ${uiOnly.hint},QQ 侧无法执行。`);
179
+ return "handled";
180
+ }
181
+
147
182
  // 1. pi extension commands, prompt templates and skill commands
148
183
  if (deps.cfg.passthrough) {
149
184
  const known = deps.listPiCommands().find((c) => c.name.toLowerCase() === name);
@@ -344,6 +379,108 @@ export function pickerExpired(picker: PickerState | null): boolean {
344
379
  return !picker || Date.now() - picker.createdAt > PICKER_TTL_MS;
345
380
  }
346
381
 
382
+ /** pi-usage-report 写入 session 的 custom entry 类型(耦合点,约定值) */
383
+ const USAGE_ENTRY_TYPE = "usage-report";
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
+
430
+ function sessionEntries(ctx: any): any[] {
431
+ try {
432
+ return ctx?.sessionManager?.getEntries?.() ?? [];
433
+ } catch {
434
+ return [];
435
+ }
436
+ }
437
+
438
+ const intText = (value: unknown): string =>
439
+ typeof value === "number" && Number.isFinite(value) ? Math.round(value).toLocaleString("en-US") : "0";
440
+
441
+ function localStamp(value: unknown): string {
442
+ if (typeof value !== "string") return "?";
443
+ const date = new Date(value);
444
+ if (Number.isNaN(date.getTime())) return "?";
445
+ const pad = (n: number) => String(n).padStart(2, "0");
446
+ return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
447
+ }
448
+
449
+ /** 把 pi-usage-report 的 ReportData 渲染成 QQ 可读的纯文本 */
450
+ function formatUsage(data: any): string {
451
+ if (!data?.totals) return "用量报告为空。";
452
+ const t = data.totals;
453
+ const money = data.money ?? {};
454
+ const usd = Number(t.costUsd ?? 0);
455
+ const rate = typeof money.rate === "number" ? money.rate : null;
456
+ const symbol = money.symbol ?? "$";
457
+ const costText = rate !== null ? `${symbol}${(usd * rate).toFixed(2)}(USD ${usd.toFixed(4)},汇率 ${rate})` : `USD ${usd.toFixed(4)}(无汇率)`;
458
+ const totalTokens = Number(t.totalTokens ?? 0);
459
+ const cachePct = totalTokens > 0 ? ((Number(t.cacheRead ?? 0) / totalTokens) * 100).toFixed(1) : "0.0";
460
+ const lines = [
461
+ `用量统计:${data.rangeLabel ?? "?"} ${localStamp(data.from)} → ${localStamp(data.to)}`,
462
+ `请求 ${intText(t.requests)} 次 合计 ${formatTokens(totalTokens)} tokens 费用 ${costText}`,
463
+ `输入 ${intText(t.input)} 输出 ${intText(t.output)} 缓存读 ${intText(t.cacheRead)}(${cachePct}%)`,
464
+ `缓存写 ${intText(t.cacheWrite)} 推理 ${intText(t.reasoning)}`,
465
+ ];
466
+ const models: any[] = Array.isArray(data.byModel) ? data.byModel : [];
467
+ if (models.length > 0) {
468
+ lines.push("按模型:");
469
+ for (const row of models.slice(0, 5)) {
470
+ const bucket = row?.bucket ?? {};
471
+ const rowUsd = Number(bucket.costUsd ?? 0);
472
+ const rowCost = rate !== null ? `${symbol}${(rowUsd * rate).toFixed(2)}` : `USD ${rowUsd.toFixed(4)}`;
473
+ lines.push(
474
+ ` ${row?.model ?? "unknown"} ${intText(bucket.requests)} 次 ${formatTokens(Number(bucket.totalTokens ?? 0))} tokens ${rowCost}`,
475
+ );
476
+ }
477
+ }
478
+ const sessions: any[] = Array.isArray(data.bySession) ? data.bySession : [];
479
+ if (sessions.length > 1) lines.push(`会话数:${sessions.length} 个`);
480
+ if (t.unpriced) lines.push(`注:${intText(t.unpriced)} 次请求没有计价。`);
481
+ return lines.join("\n");
482
+ }
483
+
347
484
  /**
348
485
  * Body of the `/qq` bridge command: pi hands it a fresh `ExtensionCommandContext`,
349
486
  * which is the only place `reload()`, `newSession()`, `fork()`, `navigateTree()` and
@@ -444,6 +581,62 @@ export async function runBridge(
444
581
  await ctx.fork(leaf, { position: "at" });
445
582
  return;
446
583
  }
584
+ case "usage": {
585
+ // /qq usage [...]:重跑原 /usage 命令,再从 session 里读回它写的 custom entry
586
+ const rest = arg.slice(first.length).trim();
587
+
588
+ // help:不执行命令(它在 TUI 打文本),直接回本地帮助
589
+ if (first === "help" || first === "?") {
590
+ reply(USAGE_HELP);
591
+ return;
592
+ }
593
+
594
+ const commands = (pi?.getCommands?.() ?? []) as { name: string }[];
595
+ const hasUsage = commands.some((c) => ["usage", "cost"].includes(String(c.name).toLowerCase()));
596
+ if (!hasUsage) {
597
+ reply("主机上没有 /usage 命令(需要安装 pi-usage-report),无法统计。");
598
+ return;
599
+ }
600
+
601
+ // rate:会写配置/联网刷新汇率,必须真的执行,再读回状态确认(不能只回一句“TUI 可见”)
602
+ if (first === "rate") {
603
+ if (!rest) {
604
+ reply(moneyText(usageMoney()));
605
+ return;
606
+ }
607
+ const rateCommand = `/usage rate ${rest}`;
608
+ try {
609
+ await pi.sendUserMessage(rateCommand, { expandPromptTemplates: true });
610
+ } catch (error) {
611
+ reply(`执行 ${rateCommand} 失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
612
+ return;
613
+ }
614
+ const after = usageMoney();
615
+ reply(
616
+ `已执行 ${rateCommand}\n${moneyText(after)}\n(下一步 /usage today 的 ¥/USD 换算就用它;无效值会被忽略)`,
617
+ );
618
+ return;
619
+ }
620
+
621
+ const command = `/usage${arg ? ` ${arg}` : ""}`;
622
+ const before = new Set(sessionEntries(ctx).map((entry: any) => entry?.id));
623
+ reply(`正在统计用量(${command})…`);
624
+ try {
625
+ await pi.sendUserMessage(command, { expandPromptTemplates: true });
626
+ } catch (error) {
627
+ reply(`统计失败:${String((error as any)?.message ?? error).slice(0, 200)}`);
628
+ return;
629
+ }
630
+ const entry = [...sessionEntries(ctx)]
631
+ .reverse()
632
+ .find((e: any) => e?.type === "custom" && e?.customType === USAGE_ENTRY_TYPE && !before.has(e?.id));
633
+ if (!entry) {
634
+ reply("没拿到用量报告(命令没有产出数据)。");
635
+ return;
636
+ }
637
+ reply(formatUsage(entry.data));
638
+ return;
639
+ }
447
640
  case "session": {
448
641
  const sm = ctx.sessionManager;
449
642
  const usage = ctx.getContextUsage?.();