@zxh126/pi-qqbot 0.2.1 → 1.0.0
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 +60 -3
- package/approver.ts +2 -2
- package/config.ts +9 -0
- package/docs/README.zh-CN.md +56 -3
- package/index.ts +145 -9
- package/package.json +1 -1
- package/slash.ts +298 -0
package/README.md
CHANGED
|
@@ -14,7 +14,9 @@ Bridge the **[Pi](https://pi.dev) coding agent** into QQ private (C2C) chats: se
|
|
|
14
14
|
| **Remote approval** | Before pi runs `bash` / `powershell` / `write` / `edit` — or any tool annotated `destructiveHint` — the bot asks in QQ first. |
|
|
15
15
|
| **Session-scoped "always allow"** | Reply 「以后都同意」 and every approval request in the **current session** is auto-approved, including the one already waiting. |
|
|
16
16
|
| **Single-instance lock** | Only one pi process hosts the QQ gateway; the others stand by and take over automatically when the host exits. |
|
|
17
|
-
| **
|
|
17
|
+
| **Cancel a running task** | Send `/cancel` (or 「取消」/「停止」) and the current run is aborted immediately — the QQ reply, the pending tool calls and the half-written answer are all cleaned up. |
|
|
18
|
+
| **Slash-command routing** | QQ messages starting with `/` are routed: pi extension commands, prompt templates and `/skill:name` are dispatched to pi; a set of pi built-ins (`/reload`, `/new`, `/compact`, `/session`, `/name`, `/model`, `/thinking`, `/quit`) is taken over; host-only commands are answered instead of being fed to the model. |
|
|
19
|
+
| **Built-in commands** | `/status`, `/reset`, `/cancel`, `/help`. |
|
|
18
20
|
|
|
19
21
|
## Install
|
|
20
22
|
|
|
@@ -53,6 +55,7 @@ Then create `~/.pi/agent/pi-qqbot.json`:
|
|
|
53
55
|
| `streaming` | `false` | Reserved, streaming is not enabled in v1 |
|
|
54
56
|
| `approve.tools` | `["bash","powershell","write","edit"]` | Tools that require approval; tools annotated `destructiveHint` are always gated too |
|
|
55
57
|
| `approve.remember` | `true` | Remember the decision per tool inside the session (no more prompts for that tool) |
|
|
58
|
+
| `commands` | see below | Slash-command routing gates: `passthrough`, `allowReload`, `allowQuit`, `allowSession` |
|
|
56
59
|
|
|
57
60
|
Credentials can also come from the `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` environment variables.
|
|
58
61
|
|
|
@@ -62,10 +65,64 @@ Start pi, then message the bot in QQ; pi handles it and replies.
|
|
|
62
65
|
|
|
63
66
|
| Command | Description |
|
|
64
67
|
|---|---|
|
|
65
|
-
| `/
|
|
68
|
+
| `/reset` | Clear the reply buffer, approval memory and session-wide auto-approve (**does not** switch sessions — send `/new` for that) |
|
|
66
69
|
| `/status` | Show busy/idle state, auto-approve state and pending approval count |
|
|
70
|
+
| `/cancel` | **Interrupt the task that is running right now** (aliases: `/stop`, `/abort`, 「取消」, 「停止」, 「中止」, 「终止」, 「算了」) |
|
|
67
71
|
| `/help` | Show help |
|
|
68
72
|
|
|
73
|
+
### Slash commands from QQ
|
|
74
|
+
|
|
75
|
+
Any message that starts with `/` is routed before it can become a chat prompt:
|
|
76
|
+
|
|
77
|
+
| # | Kind | Behaviour |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| 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 |
|
|
80
|
+
| 2 | pi built-ins this bridge can take over | re-implemented through the extension API (table below) |
|
|
81
|
+
| 3 | pi built-ins that only exist in the TUI | answered with "run it on the host", never sent to the model |
|
|
82
|
+
| 4 | everything else (`/tmp 里有什么`, `/etc 看看` …) | ordinary chat |
|
|
83
|
+
|
|
84
|
+
| QQ command | What happens | Gate |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `/reload` | reloads extensions / skills / templates / themes — **the bot restarts too** and reconnects the gateway | `commands.allowReload` (default **false**) |
|
|
87
|
+
| `/new` | real new pi session (`ctx.newSession()`) | `commands.allowSession` |
|
|
88
|
+
| `/compact [instructions]` | compacts the context | `commands.allowSession` |
|
|
89
|
+
| `/session` | session id / cwd / model / thinking level / context usage | `commands.allowSession` |
|
|
90
|
+
| `/name [name]` | get or set the session name | `commands.allowSession` |
|
|
91
|
+
| `/model [provider/model]` | list available models or switch | `commands.allowSession` |
|
|
92
|
+
| `/thinking [level]` | get or set the thinking level | `commands.allowSession` |
|
|
93
|
+
| `/quit` | shuts the host pi down | `commands.allowQuit` (default **false**) |
|
|
94
|
+
|
|
95
|
+
Host-only (answered, never forwarded to the model): `/settings`, `/hotkeys`, `/changelog`,
|
|
96
|
+
`/trust`, `/login`, `/logout`, `/scoped-models`, `/llama`, `/tree`, `/fork`, `/clone`, `/resume`,
|
|
97
|
+
`/import`, `/export`, `/share`, `/bug`, `/copy`, `/privacy`, `/handoff`.
|
|
98
|
+
|
|
99
|
+
Guards:
|
|
100
|
+
|
|
101
|
+
- `/reload`, `/new`, `/compact` and `/quit` refuse to run while a task is in progress — send `/cancel` first.
|
|
102
|
+
- `/reload` is off by default: it restarts the bot, and if the new code fails to load the bot stays
|
|
103
|
+
down until someone fixes it on the host. Turn it on with `commands.allowReload: true`.
|
|
104
|
+
- Why the indirection: pi only dispatches **extension** commands from a message
|
|
105
|
+
(`prompt()` → `_tryExecuteExtensionCommand`), and `reload()` / `newSession()` are command-context
|
|
106
|
+
APIs. So this extension registers `/qq` and forwards `/reload` → `/qq reload`. Built-in names are
|
|
107
|
+
hard-coded here because pi does not export its built-in list; any unknown `/word` falls back to
|
|
108
|
+
plain text.
|
|
109
|
+
|
|
110
|
+
### Cancelling a run
|
|
111
|
+
|
|
112
|
+
Long tasks started from QQ (installs, builds, a runaway agent loop) can be interrupted without going
|
|
113
|
+
back to the machine:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
you: /cancel
|
|
117
|
+
bot: 已取消当前任务,并终止了 1 个待审批请求。可以发送新消息继续。
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
What it does, in order: terminate every pending approval request, call `ctx.abort()` on the current
|
|
121
|
+
run (the same path as pressing Escape in the TUI), drop the partially streamed answer so it is not
|
|
122
|
+
sent to QQ, clear the busy flag so the next message is accepted immediately, and reply with a
|
|
123
|
+
confirmation. Replies that only *look* like cancelling — 「取消自动同意」, 「恢复审批」 — still go to the
|
|
124
|
+
approval handler, so the auto-approve switch keeps working as before.
|
|
125
|
+
|
|
69
126
|
Anything else is forwarded to pi as a user message.
|
|
70
127
|
|
|
71
128
|
## Remote approval
|
|
@@ -82,7 +139,7 @@ Reply vocabulary (approval keywords are Chinese, with English aliases):
|
|
|
82
139
|
|---|---|
|
|
83
140
|
| Approve one request | `同意 1` / `allow 1` — a bare `同意` / `allow` applies to the oldest pending request |
|
|
84
141
|
| Reject one request | `拒绝 1` / `deny 1` — a bare `拒绝` / `deny` applies to the oldest pending request |
|
|
85
|
-
| Abort the pending requests | `停止` |
|
|
142
|
+
| Abort the pending requests *and* the running task | `停止` / `/cancel` |
|
|
86
143
|
| Allow everything in this session | `以后都同意`, `一直同意`, `全部放行`, `别再问了`, `always allow` |
|
|
87
144
|
| Back to per-request approval | `取消自动同意`, `恢复审批`, `以后都不同意` |
|
|
88
145
|
|
package/approver.ts
CHANGED
|
@@ -29,7 +29,7 @@ const RE_ALWAYS =
|
|
|
29
29
|
/** 「取消自动同意」类表达(含「以后都不同意」) */
|
|
30
30
|
const RE_REVOKE =
|
|
31
31
|
/(取消|关闭|结束|停止)\s*自动同意|^恢复审批$|(以后|之后|今后|都|全部)\s*不(同意|允许|批准|放行)/;
|
|
32
|
-
/**
|
|
32
|
+
/** 「停止」终止本轮(index.ts 里同时会 abort 当前 run) */
|
|
33
33
|
const RE_TERMINATE = /^停止$/;
|
|
34
34
|
/** 带或不带编号的批准/拒绝 */
|
|
35
35
|
const RE_DECIDE = /^(同意|批准|放行|允许|allow|拒绝|驳回|deny)\s*(\d+)?$/i;
|
|
@@ -80,7 +80,7 @@ export class Approver {
|
|
|
80
80
|
const item = this.pending.get(seq);
|
|
81
81
|
return [
|
|
82
82
|
`[审批 #${seq}] ${item?.summary ?? ""}`,
|
|
83
|
-
`回复「同意 ${seq}」批准,「拒绝 ${seq}
|
|
83
|
+
`回复「同意 ${seq}」批准,「拒绝 ${seq}」拒绝,「停止」(/cancel)中断本轮任务。`,
|
|
84
84
|
`要本会话内全部放行,回复「以后都同意」(可用「取消自动同意」关闭)。`,
|
|
85
85
|
].join("\n");
|
|
86
86
|
}
|
package/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { CONFIG_PATH } from "./constants";
|
|
3
|
+
import { DEFAULT_COMMANDS, type CommandConfig } from "./slash";
|
|
3
4
|
|
|
4
5
|
export interface ApproveConfig {
|
|
5
6
|
/** 需要 QQ 审批的工具名(如 bash/powershell/write/edit) */
|
|
@@ -17,6 +18,8 @@ export interface QQBotConfig {
|
|
|
17
18
|
streaming: boolean;
|
|
18
19
|
toolProgress: boolean;
|
|
19
20
|
approve: ApproveConfig;
|
|
21
|
+
/** QQ 侧斜杠命令的路由开关(/reload、/new… 以及扩展命令透传) */
|
|
22
|
+
commands: CommandConfig;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export function loadConfig(): QQBotConfig | null {
|
|
@@ -41,5 +44,11 @@ export function loadConfig(): QQBotConfig | null {
|
|
|
41
44
|
tools: file.approve?.tools ?? ["bash", "powershell", "write", "edit"],
|
|
42
45
|
remember: file.approve?.remember ?? true,
|
|
43
46
|
},
|
|
47
|
+
commands: {
|
|
48
|
+
passthrough: file.commands?.passthrough ?? DEFAULT_COMMANDS.passthrough,
|
|
49
|
+
allowReload: file.commands?.allowReload ?? DEFAULT_COMMANDS.allowReload,
|
|
50
|
+
allowQuit: file.commands?.allowQuit ?? DEFAULT_COMMANDS.allowQuit,
|
|
51
|
+
allowSession: file.commands?.allowSession ?? DEFAULT_COMMANDS.allowSession,
|
|
52
|
+
},
|
|
44
53
|
};
|
|
45
54
|
}
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
| **远程审批** | pi 执行 `bash` / `powershell` / `write` / `edit`,或任何带 `destructiveHint` 注解的工具前,先在 QQ 里申请审批。 |
|
|
15
15
|
| **会话级「以后都同意」** | 回复「以后都同意」,**当前会话**内所有审批请求直接放行,连已经挂起的那条也一起放行。 |
|
|
16
16
|
| **单实例锁** | 只有一个 pi 进程作为宿主连接 QQ,其余待命,宿主退出后自动接管。 |
|
|
17
|
-
|
|
|
17
|
+
| **中断正在运行的任务** | 发 `/cancel`(或「取消」/「停止」),当前任务立刻中断 —— QQ 回复、待审批工具调用、写到一半的回答一并清理。 |
|
|
18
|
+
| **斜杠命令路由** | 以 `/` 开头的 QQ 消息会先被路由:pi 的扩展命令、prompt 模板、`/skill:name` 交给 pi 派发;一部分 pi 内置命令(`/reload`、`/new`、`/compact`、`/session`、`/name`、`/model`、`/thinking`、`/quit`)由本扩展接管;只能在主机执行的内置命令直接回执,不会送给模型。 |
|
|
19
|
+
| **内置指令** | `/status`、`/reset`、`/cancel`、`/help`。 |
|
|
18
20
|
|
|
19
21
|
## 安装
|
|
20
22
|
|
|
@@ -53,6 +55,7 @@ pi install ./pi-qqbot
|
|
|
53
55
|
| `streaming` | `false` | 预留,v1 未启用流式 |
|
|
54
56
|
| `approve.tools` | `["bash","powershell","write","edit"]` | 需要审批的工具名;带 `destructiveHint` 注解的工具同样会拦截 |
|
|
55
57
|
| `approve.remember` | `true` | 会话内按工具记住决策(同类工具不再重复询问) |
|
|
58
|
+
| `commands` | 见下 | 斜杠命令路由开关:`passthrough`、`allowReload`、`allowQuit`、`allowSession` |
|
|
56
59
|
|
|
57
60
|
也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件里的凭据。
|
|
58
61
|
|
|
@@ -62,10 +65,60 @@ pi install ./pi-qqbot
|
|
|
62
65
|
|
|
63
66
|
| 指令 | 说明 |
|
|
64
67
|
|---|---|
|
|
65
|
-
| `/
|
|
68
|
+
| `/reset` | 重置:清空回复缓冲、审批记忆与自动同意开关(**不换会话**,换会话发 `/new`) |
|
|
66
69
|
| `/status` | 查看忙闲状态、自动同意状态、待审批数量 |
|
|
70
|
+
| `/cancel` | **中断当前正在运行的任务**(别名:`/stop`、`/abort`、「取消」、「停止」、「中止」、「终止」、「算了」) |
|
|
67
71
|
| `/help` | 帮助 |
|
|
68
72
|
|
|
73
|
+
### QQ 里的斜杠命令
|
|
74
|
+
|
|
75
|
+
任何以 `/` 开头的消息都会先走路由,再决定是否当成对话:
|
|
76
|
+
|
|
77
|
+
| # | 类型 | 行为 |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| 1 | pi 的**扩展命令**、prompt 模板、`/skill:name` | 以 `expandPromptTemplates: true` 派发给 pi,所以 `/usage`、`/cost` 或你自己注册的命令在 QQ 里也能用 |
|
|
80
|
+
| 2 | 本桥接能接管的 pi 内置命令 | 用扩展 API 重新实现(见下表) |
|
|
81
|
+
| 3 | 只能在 TUI 里跑的 pi 内置命令 | 回执「请在主机执行」,**不会**送进模型 |
|
|
82
|
+
| 4 | 其余(`/tmp 里有什么`、`/etc 看看` …) | 当普通对话处理 |
|
|
83
|
+
|
|
84
|
+
| QQ 命令 | 实际动作 | 开关 |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `/reload` | 重载扩展 / 技能 / 模板 / 主题 —— **bot 自己也会重启**并重连网关 | `commands.allowReload`(默认 **false**) |
|
|
87
|
+
| `/new` | 真正开新会话(`ctx.newSession()`) | `commands.allowSession` |
|
|
88
|
+
| `/compact [指令]` | 压缩上下文 | `commands.allowSession` |
|
|
89
|
+
| `/session` | 会话 id / cwd / 模型 / 思考等级 / 上下文用量 | `commands.allowSession` |
|
|
90
|
+
| `/name [名字]` | 查看或设置会话名 | `commands.allowSession` |
|
|
91
|
+
| `/model [provider/model]` | 列出可用模型或切换 | `commands.allowSession` |
|
|
92
|
+
| `/thinking [等级]` | 查看或设置思考等级 | `commands.allowSession` |
|
|
93
|
+
| `/quit` | 关掉主机上的 pi | `commands.allowQuit`(默认 **false**) |
|
|
94
|
+
|
|
95
|
+
只能在主机执行(回执,不转发给模型):`/settings`、`/hotkeys`、`/changelog`、`/trust`、`/login`、
|
|
96
|
+
`/logout`、`/scoped-models`、`/llama`、`/tree`、`/fork`、`/clone`、`/resume`、`/import`、`/export`、
|
|
97
|
+
`/share`、`/bug`、`/copy`、`/privacy`、`/handoff`。
|
|
98
|
+
|
|
99
|
+
约束:
|
|
100
|
+
|
|
101
|
+
- 有任务在跑时,`/reload`、`/new`、`/compact`、`/quit` 会拒绝执行 —— 先发 `/cancel`。
|
|
102
|
+
- `/reload` 默认关闭:它会重启 bot,如果新代码加载失败,bot 会一直哑着,只能在主机上救。
|
|
103
|
+
置 `commands.allowReload: true` 才启用。
|
|
104
|
+
- 为什么要绕一层:pi 只对**扩展命令**执行派发(`prompt()` → `_tryExecuteExtensionCommand`),而
|
|
105
|
+
`reload()` / `newSession()` 又只能在命令上下文里调用。于是本扩展注册了 `/qq`,把 `/reload`
|
|
106
|
+
改派成 `/qq reload`。内置命令名是硬编码的(pi 没有把内置清单导出给扩展);任何不认识的
|
|
107
|
+
`/xxx` 都会退回当普通文字。
|
|
108
|
+
|
|
109
|
+
### 取消正在运行的任务
|
|
110
|
+
|
|
111
|
+
从 QQ 发起的耗时任务(装依赖、编译、agent 跑飞了)不必回到机器上处理:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
你:/cancel
|
|
115
|
+
机器人:已取消当前任务,并终止了 1 个待审批请求。可以发送新消息继续。
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
执行顺序:先终止所有待审批请求,再对当前 run 调用 `ctx.abort()`(和 TUI 里按 Escape 同一条路径),
|
|
119
|
+
丢弃已经流式产生的半截回答(不会发到 QQ),清掉忙碌标记使下一条消息立刻可用,最后回执确认。
|
|
120
|
+
像「取消自动同意」「恢复审批」这类**只是长得像**取消的回复仍然走审批分支,自动同意开关行为不变。
|
|
121
|
+
|
|
69
122
|
其余文本都作为用户消息交给 pi。
|
|
70
123
|
|
|
71
124
|
## 远程审批
|
|
@@ -82,7 +135,7 @@ pi install ./pi-qqbot
|
|
|
82
135
|
|---|---|
|
|
83
136
|
| 批准某条 | `同意 1` / `allow 1`;裸 `同意` / `allow` 作用于最早的一条待审批 |
|
|
84
137
|
| 拒绝某条 | `拒绝 1` / `deny 1`;裸 `拒绝` / `deny` 作用于最早的一条待审批 |
|
|
85
|
-
|
|
|
138
|
+
| 终止待审批 **并中断当前任务** | `停止` / `/cancel` |
|
|
86
139
|
| 本会话全部放行 | `以后都同意`、`一直同意`、`全部放行`、`别再问了`、`always allow` |
|
|
87
140
|
| 恢复逐条审批 | `取消自动同意`、`恢复审批`、`以后都不同意` |
|
|
88
141
|
|
package/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { loadConfig, QQBotConfig } from "./config";
|
|
3
3
|
import {
|
|
4
4
|
REST_BASE_PROD,
|
|
@@ -14,6 +14,7 @@ import { AuthManager } from "./qq/auth";
|
|
|
14
14
|
import { QQApi } from "./qq/api";
|
|
15
15
|
import { createGatewayUrlFetcher, QQGateway, QQMessage } from "./qq/gateway";
|
|
16
16
|
import { Approver } from "./approver";
|
|
17
|
+
import { routeSlash, runBridge } from "./slash";
|
|
17
18
|
import { ReplyBuffer } from "./stream-buffer";
|
|
18
19
|
import { splitText } from "./util/chunk";
|
|
19
20
|
|
|
@@ -28,6 +29,14 @@ interface State {
|
|
|
28
29
|
stopRetry: (() => void) | null;
|
|
29
30
|
passiveRefs: Map<string, { msgId: string; receivedAt: number }>;
|
|
30
31
|
busy: boolean;
|
|
32
|
+
/** 当前 run 的上下文(用来 abort);run 结束或取消后清空 */
|
|
33
|
+
ctx: ExtensionContext | null;
|
|
34
|
+
/** 最近一条 QQ 消息的发送者:桥接命令用它回执 */
|
|
35
|
+
lastOpenid: string | null;
|
|
36
|
+
/** /qq 桥接命令的回执通道(每次收到 QQ 消息时刷新) */
|
|
37
|
+
bridgeReply: ((text: string) => void) | null;
|
|
38
|
+
/** 本轮已被用户取消:settle 时丢弃半截回复 */
|
|
39
|
+
cancelled: boolean;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
export default function (pi: ExtensionAPI) {
|
|
@@ -42,8 +51,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
42
51
|
stopRetry: null,
|
|
43
52
|
passiveRefs: new Map(),
|
|
44
53
|
busy: false,
|
|
54
|
+
ctx: null,
|
|
55
|
+
lastOpenid: null,
|
|
56
|
+
bridgeReply: null,
|
|
57
|
+
cancelled: false,
|
|
45
58
|
};
|
|
46
59
|
|
|
60
|
+
// ---- 事件:run 开始,记住 ctx(取消指令要调用 ctx.abort())----
|
|
61
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
62
|
+
state.ctx = ctx;
|
|
63
|
+
state.cancelled = false;
|
|
64
|
+
});
|
|
65
|
+
|
|
47
66
|
// ---- 事件:累积 assistant 文本 ----
|
|
48
67
|
pi.on("message_update", (event) => {
|
|
49
68
|
const ev = event.assistantMessageEvent as any;
|
|
@@ -54,10 +73,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
54
73
|
|
|
55
74
|
// ---- 事件:run 结束,发回复 ----
|
|
56
75
|
pi.on("agent_settled", async () => {
|
|
76
|
+
const cancelled = state.cancelled;
|
|
57
77
|
const text = state.replyBuffer.take();
|
|
58
78
|
const openid = state.currentOpenid;
|
|
59
79
|
state.currentOpenid = null;
|
|
60
80
|
state.busy = false;
|
|
81
|
+
state.ctx = null;
|
|
82
|
+
state.cancelled = false;
|
|
83
|
+
if (cancelled) {
|
|
84
|
+
log("[reply] 本轮已被取消,丢弃半截回复(长度", text.length, ")");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
61
87
|
if (!text || !openid || !state.api) return;
|
|
62
88
|
log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
|
|
63
89
|
for (const chunk of splitText(text)) {
|
|
@@ -185,10 +211,85 @@ export default function (pi: ExtensionAPI) {
|
|
|
185
211
|
state.gateway = null;
|
|
186
212
|
state.api = null;
|
|
187
213
|
});
|
|
214
|
+
|
|
215
|
+
// ---- /qq:QQ 侧命令的桥接入口 ----
|
|
216
|
+
// pi 只对「扩展命令」执行派发,内置命令(/reload、/new…)只能在 TUI/RPC 层跑;
|
|
217
|
+
// 而 reload()/newSession() 又必须拿到 ExtensionCommandContext。于是注册一个扩展
|
|
218
|
+
// 命令,QQ 侧收到 /reload 时改派 /qq reload,就能在合法的命令上下文里执行。
|
|
219
|
+
pi.registerCommand("qq", {
|
|
220
|
+
description: "pi-qqbot 内部桥接:/qq <reload|new|compact|quit|session|name|model|thinking> [args]",
|
|
221
|
+
handler: async (args, ctx) => {
|
|
222
|
+
const trimmed = args.trim();
|
|
223
|
+
const space = trimmed.indexOf(" ");
|
|
224
|
+
const sub = (space === -1 ? trimmed : trimmed.slice(0, space)).toLowerCase();
|
|
225
|
+
const rest = (space === -1 ? "" : trimmed.slice(space + 1)).trim();
|
|
226
|
+
const reply = (text: string) => state.bridgeReply?.(text);
|
|
227
|
+
try {
|
|
228
|
+
await runBridge(sub, rest, ctx, pi, reply);
|
|
229
|
+
} catch (e) {
|
|
230
|
+
log("[/qq] 桥接失败:", String(e));
|
|
231
|
+
reply(`/${sub} 执行失败:${String(e).slice(0, 200)}`);
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 取消/停止这类「打断当前任务」的表达 */
|
|
238
|
+
const RE_CANCEL =
|
|
239
|
+
/^(\/cancel|\/stop|\/abort|取消|停止|中止|终止|算了|cancel|stop|abort)$/i;
|
|
240
|
+
|
|
241
|
+
function isCancelIntent(text: string): boolean {
|
|
242
|
+
return RE_CANCEL.test(text.trim());
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 用户发来取消指令:终止待审批、abort 当前 run、丢弃半截回复,然后回执。
|
|
247
|
+
* (「取消自动同意」/「关闭自动同意」不属于取消指令,仍走审批分支。)
|
|
248
|
+
*/
|
|
249
|
+
async function handleCancel(
|
|
250
|
+
state: State,
|
|
251
|
+
api: QQApi,
|
|
252
|
+
openid: string,
|
|
253
|
+
): Promise<void> {
|
|
254
|
+
const terminated = state.approver.terminateAll();
|
|
255
|
+
const running = state.busy || Boolean(state.ctx?.signal);
|
|
256
|
+
|
|
257
|
+
if (!running) {
|
|
258
|
+
await replyTo(
|
|
259
|
+
api,
|
|
260
|
+
state,
|
|
261
|
+
openid,
|
|
262
|
+
terminated > 0
|
|
263
|
+
? `已终止 ${terminated} 个待审批请求,当前没有正在运行的任务。`
|
|
264
|
+
: "当前没有正在运行的任务。",
|
|
265
|
+
);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
state.cancelled = true;
|
|
270
|
+
state.busy = false;
|
|
271
|
+
state.replyBuffer.reset();
|
|
272
|
+
state.currentOpenid = null; // 被中止的 run 不再往 QQ 发半截回复
|
|
273
|
+
const ctx = state.ctx;
|
|
274
|
+
state.ctx = null;
|
|
275
|
+
try {
|
|
276
|
+
ctx?.abort();
|
|
277
|
+
log("[cancel] 已请求中止当前 run,终止待审批", terminated, "个");
|
|
278
|
+
} catch (e) {
|
|
279
|
+
log("[cancel] abort 失败:", String(e));
|
|
280
|
+
}
|
|
281
|
+
await replyTo(
|
|
282
|
+
api,
|
|
283
|
+
state,
|
|
284
|
+
openid,
|
|
285
|
+
terminated > 0
|
|
286
|
+
? `已取消当前任务,并终止了 ${terminated} 个待审批请求。可以发送新消息继续。`
|
|
287
|
+
: "已取消当前任务。可以发送新消息继续。",
|
|
288
|
+
);
|
|
188
289
|
}
|
|
189
290
|
|
|
190
291
|
// ---- QQ 消息处理 ----
|
|
191
|
-
async function handleMessage(
|
|
292
|
+
export async function handleMessage(
|
|
192
293
|
pi: ExtensionAPI,
|
|
193
294
|
state: State,
|
|
194
295
|
api: QQApi,
|
|
@@ -203,6 +304,30 @@ async function handleMessage(
|
|
|
203
304
|
}
|
|
204
305
|
state.passiveRefs.set(msg.openid, { msgId: msg.msgId, receivedAt: Date.now() });
|
|
205
306
|
|
|
307
|
+
// 取消指令优先于审批解析:「取消」不该被当成审批动作
|
|
308
|
+
if (isCancelIntent(msg.content)) {
|
|
309
|
+
await handleCancel(state, api, msg.openid);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 斜杠命令路由:pi 扩展命令 → 可接管的内置命令 → 仅主机可用的内置命令 → 普通文字
|
|
314
|
+
state.lastOpenid = msg.openid;
|
|
315
|
+
state.bridgeReply = (text: string) => {
|
|
316
|
+
void replyTo(api, state, msg.openid, text);
|
|
317
|
+
};
|
|
318
|
+
const routed = await routeSlash(
|
|
319
|
+
{
|
|
320
|
+
listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string }[],
|
|
321
|
+
dispatch: (text: string) => pi.sendUserMessage(text, { expandPromptTemplates: true }),
|
|
322
|
+
reply: (text: string) => replyTo(api, state, msg.openid, text),
|
|
323
|
+
busy: state.busy,
|
|
324
|
+
cfg: cfg.commands,
|
|
325
|
+
log,
|
|
326
|
+
},
|
|
327
|
+
msg.content,
|
|
328
|
+
);
|
|
329
|
+
if (routed !== "text") return;
|
|
330
|
+
|
|
206
331
|
// 远程审批回复优先
|
|
207
332
|
const parsed = state.approver.parseReply(msg.content.trim());
|
|
208
333
|
if (parsed) {
|
|
@@ -263,7 +388,15 @@ async function handleMessage(
|
|
|
263
388
|
api,
|
|
264
389
|
state,
|
|
265
390
|
msg.openid,
|
|
266
|
-
[
|
|
391
|
+
[
|
|
392
|
+
"pi-qqbot 指令:",
|
|
393
|
+
"/status — 查看状态(忙闲 / 自动同意 / 待审批)",
|
|
394
|
+
"/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
|
|
395
|
+
"/cancel(或「取消」「停止」)— 中断正在运行的任务",
|
|
396
|
+
"/help — 帮助",
|
|
397
|
+
"pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
|
|
398
|
+
"其余文本交给 pi 当作对话处理。",
|
|
399
|
+
].join("\n"),
|
|
267
400
|
);
|
|
268
401
|
return;
|
|
269
402
|
}
|
|
@@ -273,17 +406,17 @@ async function handleMessage(
|
|
|
273
406
|
state,
|
|
274
407
|
msg.openid,
|
|
275
408
|
[
|
|
276
|
-
`状态: ${state.busy ? "
|
|
409
|
+
`状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : "空闲"}`,
|
|
277
410
|
`自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
|
|
278
411
|
`待审批: ${state.approver.pendingCount}`,
|
|
279
412
|
].join("\n"),
|
|
280
413
|
);
|
|
281
414
|
return;
|
|
282
415
|
}
|
|
283
|
-
if (cmd === "
|
|
416
|
+
if (cmd === "reset") {
|
|
284
417
|
state.replyBuffer.reset();
|
|
285
418
|
state.approver.reset();
|
|
286
|
-
await replyTo(api, state, msg.openid, "
|
|
419
|
+
await replyTo(api, state, msg.openid, "已重置(回复缓冲/审批记忆/自动同意)。要开新会话请发 /new。");
|
|
287
420
|
return;
|
|
288
421
|
}
|
|
289
422
|
|
|
@@ -298,6 +431,7 @@ async function handleMessage(
|
|
|
298
431
|
|
|
299
432
|
state.currentOpenid = msg.openid;
|
|
300
433
|
state.busy = true;
|
|
434
|
+
state.cancelled = false;
|
|
301
435
|
state.replyBuffer.reset();
|
|
302
436
|
|
|
303
437
|
// 交给 pi 处理
|
|
@@ -328,9 +462,11 @@ async function replyTo(
|
|
|
328
462
|
}
|
|
329
463
|
}
|
|
330
464
|
|
|
331
|
-
function parseCommand(text: string): "
|
|
332
|
-
|
|
333
|
-
|
|
465
|
+
function parseCommand(text: string): "reset" | "status" | "help" | null {
|
|
466
|
+
// 注意:/new 现在属于 pi 的内置命令(由 slash 路由接管,真的开新会话),
|
|
467
|
+
// 本地的「清缓冲/清审批记忆」改名为 /reset。
|
|
468
|
+
const m = /^\/(reset|status|help)\s*$/i.exec(text.trim());
|
|
469
|
+
return m ? (m[1].toLowerCase() as "reset" | "status" | "help") : null;
|
|
334
470
|
}
|
|
335
471
|
|
|
336
472
|
/** 从 tool_call 事件提取可读摘要,让用户在 QQ 里看清要执行什么 */
|
package/package.json
CHANGED
package/slash.ts
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash-command routing for pi-qqbot.
|
|
3
|
+
*
|
|
4
|
+
* QQ messages that start with `/` are handled in this order:
|
|
5
|
+
*
|
|
6
|
+
* 1. **pi extension commands / prompt templates / skill commands** — anything the
|
|
7
|
+
* running pi instance reports in `pi.getCommands()` (source: extension | prompt |
|
|
8
|
+
* skill). These are dispatched verbatim with
|
|
9
|
+
* `pi.sendUserMessage(text, { expandPromptTemplates: true })`, the only path pi
|
|
10
|
+
* uses to execute extension commands from a message.
|
|
11
|
+
* 2. **pi built-in commands we can take over** (`TAKEOVER`) — pi never dispatches
|
|
12
|
+
* built-ins from a message (only the TUI/RPC layer does), so these are
|
|
13
|
+
* re-implemented through the extension API. The command-only APIs
|
|
14
|
+
* (`reload`, `newSession`) need a fresh `ExtensionCommandContext`, so they run
|
|
15
|
+
* inside the `/qq` bridge command registered by this extension.
|
|
16
|
+
* 3. **built-ins we cannot reach** (`HOST_ONLY`) — reply "run it on the host".
|
|
17
|
+
* 4. **anything else** — treated as ordinary chat text (the caller forwards it).
|
|
18
|
+
*
|
|
19
|
+
* Everything is opted in conservatively through `commands` in the config file.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface SlashCommandText {
|
|
23
|
+
/** command name without the leading slash, lower-cased (`skill:name` keeps its colon) */
|
|
24
|
+
name: string;
|
|
25
|
+
/** raw text after the command name */
|
|
26
|
+
args: string;
|
|
27
|
+
/** the original message */
|
|
28
|
+
raw: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SLASH = /^\/([A-Za-z0-9:_-]+)(?:\s+([\s\S]*))?$/;
|
|
32
|
+
|
|
33
|
+
export function parseSlash(text: string): SlashCommandText | null {
|
|
34
|
+
const match = SLASH.exec(text.trim());
|
|
35
|
+
if (!match) return null;
|
|
36
|
+
return { name: match[1]!.toLowerCase(), args: (match[2] ?? "").trim(), raw: text.trim() };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type BridgeSub = "reload" | "new" | "compact" | "quit" | "session" | "name" | "model" | "thinking";
|
|
40
|
+
|
|
41
|
+
interface Takeover {
|
|
42
|
+
sub: BridgeSub;
|
|
43
|
+
/** the command tears down the current runtime/session: reply before dispatching */
|
|
44
|
+
selfDestroying?: boolean;
|
|
45
|
+
/** unsafe to run while a task is in progress */
|
|
46
|
+
needsIdle?: boolean;
|
|
47
|
+
needsArgs?: boolean;
|
|
48
|
+
hint: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* pi built-ins that can be re-implemented with extension APIs.
|
|
53
|
+
* `dist/core/slash-commands.d.ts` marks the full built-in list, but pi does not
|
|
54
|
+
* export it, so this table is maintained here on purpose.
|
|
55
|
+
*/
|
|
56
|
+
export const TAKEOVER: Readonly<Record<string, Takeover>> = {
|
|
57
|
+
reload: { sub: "reload", selfDestroying: true, needsIdle: true, hint: "重新加载扩展/技能/模板/主题" },
|
|
58
|
+
new: { sub: "new", selfDestroying: true, needsIdle: true, hint: "开启新的 pi 会话" },
|
|
59
|
+
compact: { sub: "compact", needsIdle: true, hint: "压缩上下文(可带自定义指令)" },
|
|
60
|
+
quit: { sub: "quit", selfDestroying: true, needsIdle: true, hint: "退出主机上的 pi" },
|
|
61
|
+
session: { sub: "session", hint: "查看当前会话信息与上下文用量" },
|
|
62
|
+
name: { sub: "name", hint: "设置或查看会话名称" },
|
|
63
|
+
model: { sub: "model", hint: "查看/切换模型(provider/model)" },
|
|
64
|
+
thinking: { sub: "thinking", hint: "查看/切换思考等级" },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** pi built-ins that the extension API cannot reach at all. */
|
|
68
|
+
export const HOST_ONLY: ReadonlySet<string> = new Set([
|
|
69
|
+
"settings",
|
|
70
|
+
"hotkeys",
|
|
71
|
+
"changelog",
|
|
72
|
+
"trust",
|
|
73
|
+
"login",
|
|
74
|
+
"logout",
|
|
75
|
+
"scoped-models",
|
|
76
|
+
"llama",
|
|
77
|
+
"tree",
|
|
78
|
+
"fork",
|
|
79
|
+
"clone",
|
|
80
|
+
"resume",
|
|
81
|
+
"import",
|
|
82
|
+
"export",
|
|
83
|
+
"share",
|
|
84
|
+
"bug",
|
|
85
|
+
"copy",
|
|
86
|
+
"privacy",
|
|
87
|
+
"handoff",
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
export interface CommandConfig {
|
|
91
|
+
/** forward pi extension commands / prompt templates / skill commands */
|
|
92
|
+
passthrough: boolean;
|
|
93
|
+
/** allow /reload (restarts this bot too) */
|
|
94
|
+
allowReload: boolean;
|
|
95
|
+
/** allow /quit (stops the host pi process) */
|
|
96
|
+
allowQuit: boolean;
|
|
97
|
+
/** allow session-level commands: /new, /compact, /session, /name, /model, /thinking */
|
|
98
|
+
allowSession: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const DEFAULT_COMMANDS: CommandConfig = {
|
|
102
|
+
passthrough: true,
|
|
103
|
+
allowReload: false,
|
|
104
|
+
allowQuit: false,
|
|
105
|
+
allowSession: true,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export interface RouteDeps {
|
|
109
|
+
/** commands pi currently knows about (extension/prompt/skill) */
|
|
110
|
+
listPiCommands(): readonly { name: string; source?: string }[];
|
|
111
|
+
/** deliver a pi command through pi's own dispatcher */
|
|
112
|
+
dispatch(text: string): void;
|
|
113
|
+
/** answer the QQ user */
|
|
114
|
+
reply(text: string): Promise<void>;
|
|
115
|
+
busy: boolean;
|
|
116
|
+
cfg: CommandConfig;
|
|
117
|
+
log(message: string, ...rest: unknown[]): void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type RouteResult = "dispatched" | "handled" | "text";
|
|
121
|
+
|
|
122
|
+
const allowed = (cfg: CommandConfig, name: string): boolean => {
|
|
123
|
+
if (name === "reload") return cfg.allowReload;
|
|
124
|
+
if (name === "quit") return cfg.allowQuit;
|
|
125
|
+
return cfg.allowSession;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Route one slash message. Returns "text" when it should be treated as chat. */
|
|
129
|
+
export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteResult> {
|
|
130
|
+
const parsed = parseSlash(text);
|
|
131
|
+
if (!parsed) return "text";
|
|
132
|
+
const { name } = parsed;
|
|
133
|
+
|
|
134
|
+
// 1. pi extension commands, prompt templates and skill commands
|
|
135
|
+
if (deps.cfg.passthrough) {
|
|
136
|
+
const known = deps.listPiCommands().some((c) => c.name.toLowerCase() === name);
|
|
137
|
+
if (known) {
|
|
138
|
+
deps.log("[cmd] 派发 pi 扩展命令:", parsed.raw.slice(0, 80));
|
|
139
|
+
deps.dispatch(parsed.raw);
|
|
140
|
+
await deps.reply(`已派发 /${name} 给 pi。`);
|
|
141
|
+
return "dispatched";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 2. built-ins we can take over
|
|
146
|
+
const takeover = TAKEOVER[name];
|
|
147
|
+
if (takeover) {
|
|
148
|
+
if (!allowed(deps.cfg, name)) {
|
|
149
|
+
await deps.reply(
|
|
150
|
+
`/${name} 在 QQ 侧被禁用(配置 commands.allow${name === "reload" ? "Reload" : name === "quit" ? "Quit" : "Session"})。`,
|
|
151
|
+
);
|
|
152
|
+
return "handled";
|
|
153
|
+
}
|
|
154
|
+
if (takeover.needsIdle && deps.busy) {
|
|
155
|
+
await deps.reply(`当前有任务在跑,先发 /cancel 取消,再执行 /${name}。`);
|
|
156
|
+
return "handled";
|
|
157
|
+
}
|
|
158
|
+
const bridge = takeover.needsArgs && !parsed.args ? `/qq ${takeover.sub}` : `/qq ${takeover.sub}${parsed.args ? ` ${parsed.args}` : ""}`;
|
|
159
|
+
deps.log("[cmd] 接管内置命令:", parsed.raw.slice(0, 80), "->", bridge.slice(0, 80));
|
|
160
|
+
if (takeover.selfDestroying) {
|
|
161
|
+
// the command tears the runtime down; answer before it lands
|
|
162
|
+
await deps.reply(`已请求 /${name}(${takeover.hint})…`);
|
|
163
|
+
deps.dispatch(bridge);
|
|
164
|
+
return "dispatched";
|
|
165
|
+
}
|
|
166
|
+
deps.dispatch(bridge);
|
|
167
|
+
return "dispatched";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 3. built-ins only the host TUI can run
|
|
171
|
+
if (HOST_ONLY.has(name)) {
|
|
172
|
+
await deps.reply(`/${name} 只能在主机上的 pi 里执行(扩展 API 没有对应能力)。`);
|
|
173
|
+
return "handled";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 4. not a command we know: ordinary text
|
|
177
|
+
return "text";
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const MAX_LIST = 12;
|
|
181
|
+
|
|
182
|
+
function formatTokens(value: number | null | undefined): string {
|
|
183
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "?";
|
|
184
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
|
|
185
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`;
|
|
186
|
+
return String(value);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Body of the `/qq` bridge command: pi hands it a fresh `ExtensionCommandContext`,
|
|
191
|
+
* which is the only place `reload()` / `newSession()` are legal.
|
|
192
|
+
*/
|
|
193
|
+
export async function runBridge(
|
|
194
|
+
sub: string,
|
|
195
|
+
arg: string,
|
|
196
|
+
ctx: any,
|
|
197
|
+
pi: any,
|
|
198
|
+
reply: (text: string) => void,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
const level = String(sub ?? "").toLowerCase();
|
|
201
|
+
switch (level) {
|
|
202
|
+
case "reload": {
|
|
203
|
+
await ctx.reload();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
case "new": {
|
|
207
|
+
await ctx.newSession();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
case "quit": {
|
|
211
|
+
reply("正在退出主机上的 pi…");
|
|
212
|
+
ctx.shutdown();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
case "compact": {
|
|
216
|
+
reply(arg ? `开始压缩上下文(指令:${arg.slice(0, 80)})…` : "开始压缩上下文…");
|
|
217
|
+
ctx.compact({
|
|
218
|
+
customInstructions: arg || undefined,
|
|
219
|
+
onComplete: () => reply("上下文压缩完成。"),
|
|
220
|
+
onError: (error: Error) => reply(`上下文压缩失败:${String(error?.message ?? error).slice(0, 200)}`),
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
case "session": {
|
|
225
|
+
const sm = ctx.sessionManager;
|
|
226
|
+
const usage = ctx.getContextUsage?.();
|
|
227
|
+
const model = ctx.model;
|
|
228
|
+
const entries = (() => {
|
|
229
|
+
try {
|
|
230
|
+
return sm.getEntries().length;
|
|
231
|
+
} catch {
|
|
232
|
+
return "?";
|
|
233
|
+
}
|
|
234
|
+
})();
|
|
235
|
+
const lines = [
|
|
236
|
+
`会话:${sm.getSessionName?.() || "(未命名)"}`,
|
|
237
|
+
`id:${sm.getSessionId?.() ?? "?"}`,
|
|
238
|
+
`cwd:${sm.getCwd?.() ?? ctx.cwd ?? "?"}`,
|
|
239
|
+
`模型:${model ? `${model.provider}/${model.id}` : "(未设置)"} 思考:${ctx.thinkingLevel ?? "?"}`,
|
|
240
|
+
`条目:${entries} 上下文:${usage && usage.tokens !== undefined ? `${formatTokens(usage.tokens)}${usage.percent !== undefined ? ` (${Math.round(usage.percent)}%)` : ""}` : "未知"}`,
|
|
241
|
+
];
|
|
242
|
+
reply(lines.join("\n"));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
case "name": {
|
|
246
|
+
if (!arg) {
|
|
247
|
+
reply(`当前会话名:${ctx.sessionManager.getSessionName?.() || "(未命名)"}`);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
pi.setSessionName(arg);
|
|
251
|
+
reply(`会话名已设为:${arg}`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
case "model": {
|
|
255
|
+
const registry = ctx.modelRegistry;
|
|
256
|
+
const available = registry?.getAvailable?.() ?? [];
|
|
257
|
+
const current = ctx.model;
|
|
258
|
+
if (!arg) {
|
|
259
|
+
const names = available.slice(0, MAX_LIST).map((m: any) => `${m.provider}/${m.id}`);
|
|
260
|
+
reply(
|
|
261
|
+
[
|
|
262
|
+
`当前模型:${current ? `${current.provider}/${current.id}` : "(未设置)"}`,
|
|
263
|
+
available.length > 0 ? `可选(${available.length}):${names.join(", ")}${available.length > MAX_LIST ? " …" : ""}` : "没有可用模型",
|
|
264
|
+
].join("\n"),
|
|
265
|
+
);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const wanted = arg.toLowerCase();
|
|
269
|
+
const found = available.find((m: any) => `${m.provider}/${m.id}`.toLowerCase() === wanted)
|
|
270
|
+
?? available.find((m: any) => String(m.id).toLowerCase() === wanted);
|
|
271
|
+
if (!found) {
|
|
272
|
+
reply(`找不到模型 ${arg}。发 /model 查看可用列表。`);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const ok = await pi.setModel(found);
|
|
276
|
+
reply(ok ? `模型已切换为 ${found.provider}/${found.id}` : `切换到 ${found.provider}/${found.id} 失败(缺少凭据?)`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
case "thinking": {
|
|
280
|
+
if (!arg) {
|
|
281
|
+
reply(`当前思考等级:${pi.getThinkingLevel()}`);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const levels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
285
|
+
const wanted = arg.toLowerCase();
|
|
286
|
+
if (!levels.includes(wanted)) {
|
|
287
|
+
reply(`思考等级只能是:${levels.join(" / ")}`);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
pi.setThinkingLevel(wanted);
|
|
291
|
+
reply(`思考等级已设为 ${wanted}`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
default: {
|
|
295
|
+
reply(`未知的桥接命令:/qq ${arg ? `${level} ${arg}` : level}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|