@zxh126/pi-qqbot 1.1.0 → 1.2.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 +56 -10
- package/config.ts +14 -0
- package/constants.ts +4 -0
- package/docs/README.zh-CN.md +49 -7
- package/index.ts +312 -44
- package/package.json +4 -1
- package/queue.ts +118 -0
- package/slash.ts +21 -6
package/README.md
CHANGED
|
@@ -11,12 +11,13 @@ Bridge the **[Pi](https://pi.dev) coding agent** into QQ private (C2C) chats: se
|
|
|
11
11
|
| Feature | Description |
|
|
12
12
|
|---|---|
|
|
13
13
|
| **Chat-driven agent** | QQ private messages are fed into the current pi session; the assistant's answer is sent back to QQ (long replies are split automatically). |
|
|
14
|
+
| **Message queue** | While a task is running, further QQ messages are **queued** instead of rejected; each one starts automatically as soon as the current run settles. `/cancel` is the exception — it acts immediately and clears the queue. |
|
|
14
15
|
| **Remote approval** | Before pi runs `bash` / `powershell` / `write` / `edit` — or any tool annotated `destructiveHint` — the bot asks in QQ first. |
|
|
15
16
|
| **Session-scoped "always allow"** | Reply 「以后都同意」 and every approval request in the **current session** is auto-approved, including the one already waiting. |
|
|
16
17
|
| **Single-instance lock** | Only one pi process hosts the QQ gateway; the others stand by and take over automatically when the host exits. |
|
|
17
|
-
| **Cancel a running task** | Send `/cancel` (or 「取消」/「停止」) and the current run is aborted immediately — the QQ reply, the pending tool calls
|
|
18
|
+
| **Cancel a running task** | Send `/cancel` (or 「取消」/「停止」) and the current run is aborted immediately — the QQ reply, the pending tool calls, the half-written answer and the pending queue are all cleaned up. |
|
|
18
19
|
| **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`. |
|
|
20
|
+
| **Built-in commands** | `/status`, `/queue`, `/reset`, `/cancel`, `/help`. |
|
|
20
21
|
|
|
21
22
|
## Install
|
|
22
23
|
|
|
@@ -42,6 +43,10 @@ Then create `~/.pi/agent/pi-qqbot.json`:
|
|
|
42
43
|
"approve": {
|
|
43
44
|
"tools": ["bash", "powershell", "write", "edit"],
|
|
44
45
|
"remember": true
|
|
46
|
+
},
|
|
47
|
+
"queue": {
|
|
48
|
+
"max": 20,
|
|
49
|
+
"ack": true
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
```
|
|
@@ -56,6 +61,8 @@ Then create `~/.pi/agent/pi-qqbot.json`:
|
|
|
56
61
|
| `approve.tools` | `["bash","powershell","write","edit"]` | Tools that require approval; tools annotated `destructiveHint` are always gated too |
|
|
57
62
|
| `approve.remember` | `true` | Remember the decision per tool inside the session (no more prompts for that tool) |
|
|
58
63
|
| `commands` | see below | Slash-command routing gates: `passthrough`, `allowReload`, `allowQuit`, `allowSession` |
|
|
64
|
+
| `queue.max` | `20` | How many messages may wait while a task runs; `0` disables queueing (busy messages are refused as before) |
|
|
65
|
+
| `queue.ack` | `true` | Reply to QQ when a queued [message](#queueing-while-busy) starts (the "queued" ack is always sent) |
|
|
59
66
|
|
|
60
67
|
Credentials can also come from the `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` environment variables.
|
|
61
68
|
|
|
@@ -66,8 +73,9 @@ Start pi, then message the bot in QQ; pi handles it and replies.
|
|
|
66
73
|
| Command | Description |
|
|
67
74
|
|---|---|
|
|
68
75
|
| `/reset` | Clear the reply buffer, approval memory and session-wide auto-approve (**does not** switch sessions — send `/new` for that) |
|
|
69
|
-
| `/status` | Show busy/idle state, auto-approve state and pending approval count |
|
|
70
|
-
| `/
|
|
76
|
+
| `/status` | Show busy/idle state, queue length, auto-approve state and pending approval count |
|
|
77
|
+
| `/queue` | Show the queue (aliases: `/q`); `/queue clear` empties it, `/queue drop <id>` removes one entry |
|
|
78
|
+
| `/cancel` | **Interrupt the task that is running right now** and empty the queue (aliases: `/stop`, `/abort`, 「取消」, 「停止」, 「中止」, 「终止」, 「算了」) |
|
|
71
79
|
| `/help` | Show help |
|
|
72
80
|
|
|
73
81
|
### Slash commands from QQ
|
|
@@ -104,6 +112,9 @@ Guards:
|
|
|
104
112
|
|
|
105
113
|
- `/reload`, `/new`, `/compact`, `/quit`, `/tree`, `/fork`, `/clone` and `/resume` refuse to run while a task
|
|
106
114
|
is in progress — send `/cancel` first.
|
|
115
|
+
- Prompt templates and `/skill:name` start an agent run, so while a task is in progress they are
|
|
116
|
+
**queued** like ordinary messages (see [Queueing while busy](#queueing-while-busy)). Extension commands
|
|
117
|
+
that manage their own LLM interaction still run immediately.
|
|
107
118
|
- Selection commands (`/tree`, `/fork`, `/resume`) answer with a numbered list of at most 10 entries, scoped to
|
|
108
119
|
the user who asked and valid for 10 minutes. The next bare number from that user executes the choice; anything
|
|
109
120
|
else (or 「取消」) abandons it. A number without a pending list is ordinary chat.
|
|
@@ -115,6 +126,39 @@ Guards:
|
|
|
115
126
|
hard-coded here because pi does not export its built-in list; any unknown `/word` falls back to
|
|
116
127
|
plain text.
|
|
117
128
|
|
|
129
|
+
### Queueing while busy
|
|
130
|
+
|
|
131
|
+
pi's session runs one agent run at a time, so the bot serializes QQ input itself:
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
you: 帮我把测试跑一遍
|
|
135
|
+
bot: 收到,正在处理
|
|
136
|
+
you: 顺便看看 README 有没有错别字
|
|
137
|
+
bot: 已排队 #1,当前任务结束后自动执行。发 /queue 查看队列,/cancel 可中止当前任务并清空队列。
|
|
138
|
+
you: /queue
|
|
139
|
+
bot: 当前任务:处理中(发 /cancel 可中断)
|
|
140
|
+
队列:1 条(上限 20)
|
|
141
|
+
1. #1 · 12s 前 · 顺便看看 README 有没有错别字
|
|
142
|
+
发 /queue clear 清空,/queue drop <编号> 删除一条。
|
|
143
|
+
bot: (第一轮结束后)开始执行队列 #1
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Semantics:
|
|
147
|
+
|
|
148
|
+
- Every message that would start a run — plain chat, prompt templates, `/skill:name` — is queued while a
|
|
149
|
+
task is running, in arrival order (FIFO). Replies go to the QQ user who sent that message.
|
|
150
|
+
- The next entry starts when pi has **settled** (`agent_settled`, no automatic continuation left), so a
|
|
151
|
+
retry, a compaction retry or a follow-up turn is never cut in half.
|
|
152
|
+
- The queue holds at most `queue.max` entries; beyond that the message is refused with a notice. `queue.max: 0`
|
|
153
|
+
restores the old "上一条消息还在处理中" behaviour.
|
|
154
|
+
- If handing a message to pi fails (pi was still finishing the previous run), it is put back at the head of
|
|
155
|
+
the queue and retried — messages are not lost. A run that already started is never retried, so a failure
|
|
156
|
+
inside a run cannot execute the same message twice.
|
|
157
|
+
- Control commands are **not** queued: approvals, `/status`, `/queue`, `/reset`, `/help`, the pi built-ins
|
|
158
|
+
and `/cancel` all act immediately.
|
|
159
|
+
- `/cancel` empties the queue as well, because "cancel" means "stop doing all of this". Use `/queue drop <id>`
|
|
160
|
+
to remove a single entry instead.
|
|
161
|
+
|
|
118
162
|
### Cancelling a run
|
|
119
163
|
|
|
120
164
|
Long tasks started from QQ (installs, builds, a runaway agent loop) can be interrupted without going
|
|
@@ -122,14 +166,16 @@ back to the machine:
|
|
|
122
166
|
|
|
123
167
|
```
|
|
124
168
|
you: /cancel
|
|
125
|
-
bot: 已取消当前任务,并终止了 1
|
|
169
|
+
bot: 已取消当前任务,并终止了 1 个待审批请求,队列中的 2 条已清空。可以发送新消息继续。
|
|
126
170
|
```
|
|
127
171
|
|
|
128
|
-
What it does, in order: terminate every pending approval request, call `ctx.abort()` on
|
|
129
|
-
run (the same path as pressing Escape in the TUI), drop the partially streamed answer so it is
|
|
130
|
-
sent to QQ,
|
|
131
|
-
|
|
132
|
-
|
|
172
|
+
What it does, in order: terminate every pending approval request, empty the queue, call `ctx.abort()` on
|
|
173
|
+
the current run (the same path as pressing Escape in the TUI), drop the partially streamed answer so it is
|
|
174
|
+
not sent to QQ, keep the busy flag until pi reports `agent_settled` (so messages arriving during the abort
|
|
175
|
+
are queued instead of racing it), and reply with a confirmation. If `agent_settled` never arrives, an
|
|
176
|
+
8-second watchdog releases the busy flag and continues the queue. Replies that only *look* like cancelling
|
|
177
|
+
— 「取消自动同意」, 「恢复审批」 — still go to the approval handler, so the auto-approve switch keeps working
|
|
178
|
+
as before.
|
|
133
179
|
|
|
134
180
|
Anything else is forwarded to pi as a user message.
|
|
135
181
|
|
package/config.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { CONFIG_PATH } from "./constants";
|
|
3
3
|
import { DEFAULT_COMMANDS, type CommandConfig } from "./slash";
|
|
4
|
+
import { DEFAULT_QUEUE_MAX } from "./queue";
|
|
5
|
+
|
|
6
|
+
export interface QueueConfig {
|
|
7
|
+
/** 忙碌时最多排队多少条消息;0 = 关闭排队(忙碌时直接拒绝) */
|
|
8
|
+
max: number;
|
|
9
|
+
/** 排队任务开始执行时是否给 QQ 回执(入队回执不受此开关控制) */
|
|
10
|
+
ack: boolean;
|
|
11
|
+
}
|
|
4
12
|
|
|
5
13
|
export interface ApproveConfig {
|
|
6
14
|
/** 需要 QQ 审批的工具名(如 bash/powershell/write/edit) */
|
|
@@ -20,6 +28,8 @@ export interface QQBotConfig {
|
|
|
20
28
|
approve: ApproveConfig;
|
|
21
29
|
/** QQ 侧斜杠命令的路由开关(/reload、/new… 以及扩展命令透传) */
|
|
22
30
|
commands: CommandConfig;
|
|
31
|
+
/** 忙碌时的消息队列 */
|
|
32
|
+
queue: QueueConfig;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
export function loadConfig(): QQBotConfig | null {
|
|
@@ -50,5 +60,9 @@ export function loadConfig(): QQBotConfig | null {
|
|
|
50
60
|
allowQuit: file.commands?.allowQuit ?? DEFAULT_COMMANDS.allowQuit,
|
|
51
61
|
allowSession: file.commands?.allowSession ?? DEFAULT_COMMANDS.allowSession,
|
|
52
62
|
},
|
|
63
|
+
queue: {
|
|
64
|
+
max: file.queue?.max ?? DEFAULT_QUEUE_MAX,
|
|
65
|
+
ack: file.queue?.ack ?? true,
|
|
66
|
+
},
|
|
53
67
|
};
|
|
54
68
|
}
|
package/constants.ts
CHANGED
|
@@ -10,6 +10,10 @@ export const MAX_REPLIES_PER_MSG_ID = 4;
|
|
|
10
10
|
export const APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
11
11
|
export const STREAM_FLUSH_INTERVAL_MS = 1200;
|
|
12
12
|
export const GATEWAY_PATH = "/gateway";
|
|
13
|
+
/** 队列任务派发给 pi 失败(通常是上一轮还没收尾)时的重试间隔 */
|
|
14
|
+
export const DISPATCH_RETRY_MS = 3000;
|
|
15
|
+
/** /cancel 之后等 agent_settled 的宽限时间,超时就强制释放 busy */
|
|
16
|
+
export const CANCEL_SETTLE_GRACE_MS = 8000;
|
|
13
17
|
|
|
14
18
|
const PI_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
15
19
|
export const CONFIG_PATH = () => path.join(PI_DIR, "pi-qqbot.json");
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
| 功能 | 说明 |
|
|
12
12
|
|---|---|
|
|
13
13
|
| **对话驱动 agent** | QQ 单聊消息送入当前 pi 会话,AI 的回复发回 QQ(长文本自动分片)。 |
|
|
14
|
+
| **消息队列** | 正在处理任务时,后续 QQ 消息会被**排队**而不是被拒;当前任务结束(settled)后按到达顺序自动逐条执行。`/cancel` 是例外——立即生效并清空队列。 |
|
|
14
15
|
| **远程审批** | pi 执行 `bash` / `powershell` / `write` / `edit`,或任何带 `destructiveHint` 注解的工具前,先在 QQ 里申请审批。 |
|
|
15
16
|
| **会话级「以后都同意」** | 回复「以后都同意」,**当前会话**内所有审批请求直接放行,连已经挂起的那条也一起放行。 |
|
|
16
17
|
| **单实例锁** | 只有一个 pi 进程作为宿主连接 QQ,其余待命,宿主退出后自动接管。 |
|
|
17
|
-
| **中断正在运行的任务** | 发 `/cancel`(或「取消」/「停止」),当前任务立刻中断 —— QQ
|
|
18
|
+
| **中断正在运行的任务** | 发 `/cancel`(或「取消」/「停止」),当前任务立刻中断 —— QQ 回复、待审批工具调用、写到一半的回答、排队中的消息一并清理。 |
|
|
18
19
|
| **斜杠命令路由** | 以 `/` 开头的 QQ 消息会先被路由:pi 的扩展命令、prompt 模板、`/skill:name` 交给 pi 派发;一部分 pi 内置命令(`/reload`、`/new`、`/compact`、`/session`、`/name`、`/model`、`/thinking`、`/quit`)由本扩展接管;只能在主机执行的内置命令直接回执,不会送给模型。 |
|
|
19
|
-
| **内置指令** | `/status`、`/reset`、`/cancel`、`/help`。 |
|
|
20
|
+
| **内置指令** | `/status`、`/queue`、`/reset`、`/cancel`、`/help`。 |
|
|
20
21
|
|
|
21
22
|
## 安装
|
|
22
23
|
|
|
@@ -42,6 +43,10 @@ pi install ./pi-qqbot
|
|
|
42
43
|
"approve": {
|
|
43
44
|
"tools": ["bash", "powershell", "write", "edit"],
|
|
44
45
|
"remember": true
|
|
46
|
+
},
|
|
47
|
+
"queue": {
|
|
48
|
+
"max": 20,
|
|
49
|
+
"ack": true
|
|
45
50
|
}
|
|
46
51
|
}
|
|
47
52
|
```
|
|
@@ -56,6 +61,8 @@ pi install ./pi-qqbot
|
|
|
56
61
|
| `approve.tools` | `["bash","powershell","write","edit"]` | 需要审批的工具名;带 `destructiveHint` 注解的工具同样会拦截 |
|
|
57
62
|
| `approve.remember` | `true` | 会话内按工具记住决策(同类工具不再重复询问) |
|
|
58
63
|
| `commands` | 见下 | 斜杠命令路由开关:`passthrough`、`allowReload`、`allowQuit`、`allowSession` |
|
|
64
|
+
| `queue.max` | `20` | 任务运行期间最多排队多少条;`0` = 关闭排队(忙碌时直接拒绍,回到旧行为) |
|
|
65
|
+
| `queue.ack` | `true` | 排队消息开始执行时是否回执 QQ(入队回执总是会发) |
|
|
59
66
|
|
|
60
67
|
也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件里的凭据。
|
|
61
68
|
|
|
@@ -66,8 +73,9 @@ pi install ./pi-qqbot
|
|
|
66
73
|
| 指令 | 说明 |
|
|
67
74
|
|---|---|
|
|
68
75
|
| `/reset` | 重置:清空回复缓冲、审批记忆与自动同意开关(**不换会话**,换会话发 `/new`) |
|
|
69
|
-
| `/status` |
|
|
70
|
-
| `/
|
|
76
|
+
| `/status` | 查看忙闲状态、队列长度、自动同意状态、待审批数量 |
|
|
77
|
+
| `/queue` | 查看队列(别名 `/q`);`/queue clear` 清空,`/queue drop <编号>` 删一条 |
|
|
78
|
+
| `/cancel` | **中断当前正在运行的任务**并清空队列(别名:`/stop`、`/abort`、「取消」、「停止」、「中止」、「终止」、「算了」) |
|
|
71
79
|
| `/help` | 帮助 |
|
|
72
80
|
|
|
73
81
|
### QQ 里的斜杠命令
|
|
@@ -103,6 +111,8 @@ pi install ./pi-qqbot
|
|
|
103
111
|
|
|
104
112
|
- 有任务在跑时,`/reload`、`/new`、`/compact`、`/quit`、`/tree`、`/fork`、`/clone`、`/resume` 会拒绝执行
|
|
105
113
|
—— 先发 `/cancel`。
|
|
114
|
+
- prompt 模板与 `/skill:name` 会开启一轮 agent run,因此忙碌时会像普通消息一样**排队**
|
|
115
|
+
(见 [忙碌时的队列](#忙碌时的队列));自己管理 LLM 交互的扩展命令仍然立即执行。
|
|
106
116
|
- 选择型命令(`/tree`、`/fork`、`/resume`)会回一个最多 10 项的编号列表,只对该用户有效、10 分钟内有效;
|
|
107
117
|
该用户下一条「纯数字」消息即执行选择,其它内容(或「取消」)则放弃。没有待选列表时,数字就是普通对话。
|
|
108
118
|
- `/reload` 默认关闭:它会重启 bot,如果新代码加载失败,bot 会一直哑着,只能在主机上救。
|
|
@@ -112,17 +122,49 @@ pi install ./pi-qqbot
|
|
|
112
122
|
改派成 `/qq reload`。内置命令名是硬编码的(pi 没有把内置清单导出给扩展);任何不认识的
|
|
113
123
|
`/xxx` 都会退回当普通文字。
|
|
114
124
|
|
|
125
|
+
### 忙碌时的队列
|
|
126
|
+
|
|
127
|
+
pi 的会话一次只跑一轮 agent run,所以串行调度由 bot 自己做:
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
你:帮我把测试跑一遍
|
|
131
|
+
机器人:收到,正在处理
|
|
132
|
+
你:顺便看看 README 有没有错别字
|
|
133
|
+
机器人:已排队 #1,当前任务结束后自动执行。发 /queue 查看队列,/cancel 可中止当前任务并清空队列。
|
|
134
|
+
你:/queue
|
|
135
|
+
机器人:当前任务:处理中(发 /cancel 可中断)
|
|
136
|
+
队列:1 条(上限 20)
|
|
137
|
+
1. #1 · 12s 前 · 顺便看看 README 有没有错别字
|
|
138
|
+
发 /queue clear 清空,/queue drop <编号> 删除一条。
|
|
139
|
+
机器人:(第一轮结束后)开始执行队列 #1
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
语义:
|
|
143
|
+
|
|
144
|
+
- 会开启一轮 run 的消息(普通对话、prompt 模板、`/skill:name`)在忙碌时按到达顺序(FIFO)排队;
|
|
145
|
+
回复发给该条消息的发送者。
|
|
146
|
+
- 下一条在 pi **settled**(不再有任何自动续跑)之后才启动,所以重试、压缩重试、follow-up 轮次
|
|
147
|
+
不会被从中间撗断。
|
|
148
|
+
- 队列上限为 `queue.max`,超出时拒绍入队并回执;`queue.max: 0` 回到旧的「上一条消息还在处理中」。
|
|
149
|
+
- 交给 pi 失败时(通常上一轮还没收尾)任务会放回队首重试,不丢消息;已经真的启动过 run 的报错
|
|
150
|
+
不会重试,避免同一条消息执行两次。
|
|
151
|
+
- 控制类指令**不排队**:审批回复、`/status`、`/queue`、`/reset`、`/help`、pi 内置命令与 `/cancel`
|
|
152
|
+
都立即生效。
|
|
153
|
+
- `/cancel` 会连队列一起清空(“取消”就是“都别做了”);只想删一条请用 `/queue drop <编号>`。
|
|
154
|
+
|
|
115
155
|
### 取消正在运行的任务
|
|
116
156
|
|
|
117
157
|
从 QQ 发起的耗时任务(装依赖、编译、agent 跑飞了)不必回到机器上处理:
|
|
118
158
|
|
|
119
159
|
```
|
|
120
160
|
你:/cancel
|
|
121
|
-
机器人:已取消当前任务,并终止了 1
|
|
161
|
+
机器人:已取消当前任务,并终止了 1 个待审批请求,队列中的 2 条已清空。可以发送新消息继续。
|
|
122
162
|
```
|
|
123
163
|
|
|
124
|
-
|
|
125
|
-
丢弃已经流式产生的半截回答(不会发到 QQ
|
|
164
|
+
执行顺序:先终止所有待审批请求、清空队列,再对当前 run 调用 `ctx.abort()`(和 TUI 里按 Escape 同一条路径),
|
|
165
|
+
丢弃已经流式产生的半截回答(不会发到 QQ),**忙碌标记保留到 pi 报 `agent_settled`**
|
|
166
|
+
(所以 abort 期间新来的消息会排队,而不是和收尾中的 run 撞车),最后回执确认。如果 `agent_settled`
|
|
167
|
+
一直不来,8 秒看门狗会强制释放忙碌标记并继续队列。
|
|
126
168
|
像「取消自动同意」「恢复审批」这类**只是长得像**取消的回复仍然走审批分支,自动同意开关行为不变。
|
|
127
169
|
|
|
128
170
|
其余文本都作为用户消息交给 pi。
|
package/index.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
PASSIVE_WINDOW_MS,
|
|
8
8
|
APPROVAL_TIMEOUT_MS,
|
|
9
9
|
LOCK_PATH,
|
|
10
|
+
DISPATCH_RETRY_MS,
|
|
11
|
+
CANCEL_SETTLE_GRACE_MS,
|
|
10
12
|
} from "./constants";
|
|
11
13
|
import { log } from "./logger";
|
|
12
14
|
import { tryAcquireLock, startLockRetry } from "./lock";
|
|
@@ -15,6 +17,7 @@ import { QQApi } from "./qq/api";
|
|
|
15
17
|
import { createGatewayUrlFetcher, QQGateway, QQMessage } from "./qq/gateway";
|
|
16
18
|
import { Approver } from "./approver";
|
|
17
19
|
import { parsePickIndex, pickerExpired, routeSlash, runBridge, type PickerState } from "./slash";
|
|
20
|
+
import { agoLabel, MessageQueue, previewText, type QueuedJob } from "./queue";
|
|
18
21
|
import { ReplyBuffer } from "./stream-buffer";
|
|
19
22
|
import { splitText } from "./util/chunk";
|
|
20
23
|
|
|
@@ -41,10 +44,19 @@ interface State {
|
|
|
41
44
|
bridgeReply: ((text: string) => void) | null;
|
|
42
45
|
/** 本轮已被用户取消:settle 时丢弃半截回复 */
|
|
43
46
|
cancelled: boolean;
|
|
47
|
+
/** 忙碌时收到的任务:当前任务 settle 后逐条执行 */
|
|
48
|
+
queue: MessageQueue;
|
|
49
|
+
/** 正在出队派发(防 settled / 重试竞态下重复派发) */
|
|
50
|
+
dispatching: boolean;
|
|
51
|
+
/** 当前派发的任务已经真的启动了 run(agent_start 过):此时报错不重试,避免重复执行 */
|
|
52
|
+
runStarted: boolean;
|
|
53
|
+
/** /cancel 之后等 agent_settled 的兜底定时器 */
|
|
54
|
+
cancelTimer: ReturnType<typeof setTimeout> | null;
|
|
44
55
|
}
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
/** 扩展运行所需的状态(导出只为测试用) */
|
|
58
|
+
export function createInitialState(): State {
|
|
59
|
+
return {
|
|
48
60
|
cfg: null,
|
|
49
61
|
api: null,
|
|
50
62
|
approver: new Approver(APPROVAL_TIMEOUT_MS),
|
|
@@ -61,12 +73,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
61
73
|
pickerOwner: null,
|
|
62
74
|
bridgeReply: null,
|
|
63
75
|
cancelled: false,
|
|
76
|
+
queue: new MessageQueue(),
|
|
77
|
+
dispatching: false,
|
|
78
|
+
runStarted: false,
|
|
79
|
+
cancelTimer: null,
|
|
64
80
|
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default function (
|
|
84
|
+
pi: ExtensionAPI,
|
|
85
|
+
/** 仅测试用:注入状态,让事件处理器与 handleMessage 共享同一份状态 */
|
|
86
|
+
options?: { state?: State },
|
|
87
|
+
) {
|
|
88
|
+
const state: State = options?.state ?? createInitialState();
|
|
65
89
|
|
|
66
90
|
// ---- 事件:run 开始,记住 ctx(取消指令要调用 ctx.abort())----
|
|
67
91
|
pi.on("agent_start", (_event, ctx) => {
|
|
68
92
|
state.ctx = ctx;
|
|
69
93
|
state.cancelled = false;
|
|
94
|
+
state.runStarted = true;
|
|
95
|
+
clearCancelTimer(state);
|
|
70
96
|
});
|
|
71
97
|
|
|
72
98
|
// ---- 事件:累积 assistant 文本 ----
|
|
@@ -77,8 +103,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
77
103
|
}
|
|
78
104
|
});
|
|
79
105
|
|
|
80
|
-
// ---- 事件:run
|
|
106
|
+
// ---- 事件:run 结束,发回复,然后接着跑队列里的下一条 ----
|
|
81
107
|
pi.on("agent_settled", async () => {
|
|
108
|
+
clearCancelTimer(state);
|
|
82
109
|
const cancelled = state.cancelled;
|
|
83
110
|
const text = state.replyBuffer.take();
|
|
84
111
|
const openid = state.currentOpenid;
|
|
@@ -86,15 +113,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
86
113
|
state.busy = false;
|
|
87
114
|
state.ctx = null;
|
|
88
115
|
state.cancelled = false;
|
|
116
|
+
state.runStarted = false;
|
|
89
117
|
if (cancelled) {
|
|
90
118
|
log("[reply] 本轮已被取消,丢弃半截回复(长度", text.length, ")");
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
await state.api.sendC2C(openid, chunk).catch((e) => log("[reply] 发送失败:", String(e)));
|
|
119
|
+
} else if (text && openid && state.api) {
|
|
120
|
+
log("[reply] agent_settled,回复 openid=", openid, "text=", text.slice(0, 80));
|
|
121
|
+
for (const chunk of splitText(text)) {
|
|
122
|
+
await state.api.sendC2C(openid, chunk).catch((e) => log("[reply] 发送失败:", String(e)));
|
|
123
|
+
}
|
|
97
124
|
}
|
|
125
|
+
// 本轮结束:取队列里的下一条继续执行(不 await,避免卡住 settled 的派发阶段)
|
|
126
|
+
drainQueue(pi, state);
|
|
98
127
|
});
|
|
99
128
|
|
|
100
129
|
// ---- 事件:tool_call 审批 ----
|
|
@@ -163,7 +192,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
163
192
|
state.cfg = cfg;
|
|
164
193
|
// 新会话:清空 remember 记忆与自动同意开关(自动同意只在当前会话有效)
|
|
165
194
|
state.approver.reset();
|
|
166
|
-
|
|
195
|
+
// 队列上限来自配置;新会话不继承上一个会话遗留的待执行任务
|
|
196
|
+
state.queue.setLimit(cfg.queue.max);
|
|
197
|
+
state.queue.clear();
|
|
198
|
+
log("[setup] 配置已加载 sandbox=", cfg.sandbox, "queue.max=", cfg.queue.max);
|
|
167
199
|
|
|
168
200
|
const restBase = cfg.sandbox ? REST_BASE_SANDBOX : REST_BASE_PROD;
|
|
169
201
|
const auth = new AuthManager(cfg.appId, cfg.appSecret);
|
|
@@ -204,6 +236,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
204
236
|
// ---- 事件:session_shutdown 清理 ----
|
|
205
237
|
pi.on("session_shutdown", async () => {
|
|
206
238
|
log("[shutdown] 清理资源");
|
|
239
|
+
clearCancelTimer(state);
|
|
240
|
+
const dropped = state.queue.clear();
|
|
241
|
+
if (dropped.length > 0) log("[shutdown] 丢弃队列任务", dropped.length, "条");
|
|
207
242
|
if (state.stopRetry) state.stopRetry();
|
|
208
243
|
state.gateway?.stop();
|
|
209
244
|
if (state.releaseLock) {
|
|
@@ -248,49 +283,220 @@ function isCancelIntent(text: string): boolean {
|
|
|
248
283
|
return RE_CANCEL.test(text.trim());
|
|
249
284
|
}
|
|
250
285
|
|
|
286
|
+
/** 派发失败时最多重试几次(通常是 pi 上一轮还没收尾) */
|
|
287
|
+
const MAX_DISPATCH_ATTEMPTS = 3;
|
|
288
|
+
|
|
289
|
+
function clearCancelTimer(state: State) {
|
|
290
|
+
if (state.cancelTimer) {
|
|
291
|
+
clearTimeout(state.cancelTimer);
|
|
292
|
+
state.cancelTimer = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 对外看是“忙”:有任务在跑、正在派发,或者队列里还有任务等着(比如派发失败后的重试窗口)。
|
|
298
|
+
* 这三种情况新消息都应该进队列,而不是插队直接开新一轮。
|
|
299
|
+
*/
|
|
300
|
+
function isBusy(state: State): boolean {
|
|
301
|
+
return state.busy || state.dispatching || state.queue.size > 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* /cancel 之后 abort() 是异步的:正常路径靠 agent_settled 释放 busy 并继续队列;
|
|
306
|
+
* 万一 abort 没能触发 settled(比如那一轮正好已经结束),宽限期后强制解锁。
|
|
307
|
+
*/
|
|
308
|
+
function armCancelWatchdog(pi: ExtensionAPI, state: State) {
|
|
309
|
+
clearCancelTimer(state);
|
|
310
|
+
const timer = setTimeout(() => {
|
|
311
|
+
state.cancelTimer = null;
|
|
312
|
+
if (!state.cancelled) return;
|
|
313
|
+
log("[cancel] abort 后", CANCEL_SETTLE_GRACE_MS, "ms 内没有 agent_settled,强制释放 busy");
|
|
314
|
+
state.cancelled = false;
|
|
315
|
+
state.busy = false;
|
|
316
|
+
state.ctx = null;
|
|
317
|
+
state.currentOpenid = null;
|
|
318
|
+
state.replyBuffer.reset();
|
|
319
|
+
drainQueue(pi, state);
|
|
320
|
+
}, CANCEL_SETTLE_GRACE_MS);
|
|
321
|
+
(timer as any).unref?.();
|
|
322
|
+
state.cancelTimer = timer;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 把一条任务交给 pi,并把它标记为「当前任务」。
|
|
327
|
+
* 不 await sendUserMessage:它返回的 promise 要等整轮跑完,await 会卡住调用方
|
|
328
|
+
* (agent_settled 里派发时更是不允许)。错误交给 onDispatchError 重试/回执。
|
|
329
|
+
*/
|
|
330
|
+
function startJob(pi: ExtensionAPI, state: State, job: QueuedJob) {
|
|
331
|
+
state.currentOpenid = job.openid;
|
|
332
|
+
state.lastOpenid = job.openid;
|
|
333
|
+
state.pickerOwner = job.openid;
|
|
334
|
+
state.bridgeReply = (text: string) => {
|
|
335
|
+
void replyTo(state.api!, state, job.openid, text);
|
|
336
|
+
};
|
|
337
|
+
state.busy = true;
|
|
338
|
+
state.cancelled = false;
|
|
339
|
+
state.runStarted = false;
|
|
340
|
+
state.replyBuffer.reset();
|
|
341
|
+
clearCancelTimer(state);
|
|
342
|
+
log(
|
|
343
|
+
"[msg] 送入 pi:",
|
|
344
|
+
job.id > 0 ? `队列 #${job.id}` : "直接",
|
|
345
|
+
"openid=",
|
|
346
|
+
job.openid,
|
|
347
|
+
"text=",
|
|
348
|
+
job.text.slice(0, 60),
|
|
349
|
+
);
|
|
350
|
+
try {
|
|
351
|
+
void Promise.resolve(
|
|
352
|
+
pi.sendUserMessage(job.text, { expandPromptTemplates: true, deliverAs: "followUp" }),
|
|
353
|
+
).catch((error) => onDispatchError(pi, state, job, error));
|
|
354
|
+
} catch (error) {
|
|
355
|
+
onDispatchError(pi, state, job, error);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** 派发失败:先重试(放回队首),多次失败就把错误回给发送者并继续队列 */
|
|
360
|
+
function onDispatchError(pi: ExtensionAPI, state: State, job: QueuedJob, error: unknown) {
|
|
361
|
+
const message = String((error as any)?.message ?? error).slice(0, 200);
|
|
362
|
+
log("[msg] 派发失败:", message);
|
|
363
|
+
if (state.runStarted) {
|
|
364
|
+
// run 已经启动了:这次报错属于这一轮自己的问题,收尾交给 agent_settled,不能重试
|
|
365
|
+
log("[msg] 本轮已启动过 agent,不重试,等 agent_settled 收尾");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
state.busy = false;
|
|
369
|
+
state.currentOpenid = null;
|
|
370
|
+
job.attempts += 1;
|
|
371
|
+
if (job.attempts < MAX_DISPATCH_ATTEMPTS) {
|
|
372
|
+
state.queue.unshift(job);
|
|
373
|
+
log("[msg]", DISPATCH_RETRY_MS, "ms 后重试(第", job.attempts, "次)");
|
|
374
|
+
const timer = setTimeout(() => drainQueue(pi, state), DISPATCH_RETRY_MS);
|
|
375
|
+
(timer as any).unref?.();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const api = state.api;
|
|
379
|
+
if (api) void replyTo(api, state, job.openid, `这条消息执行失败:${message}`);
|
|
380
|
+
drainQueue(pi, state);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** 当前任务结束后,取队列里的下一条开始执行(空闲时才会真的派发) */
|
|
384
|
+
function drainQueue(pi: ExtensionAPI, state: State) {
|
|
385
|
+
if (state.busy || state.dispatching) return;
|
|
386
|
+
const job = state.queue.shift();
|
|
387
|
+
if (!job) return;
|
|
388
|
+
state.dispatching = true;
|
|
389
|
+
try {
|
|
390
|
+
log("[queue] 出队 #", job.id, "剩余", state.queue.size, "条", "text=", job.text.slice(0, 60));
|
|
391
|
+
const api = state.api;
|
|
392
|
+
if (api && state.cfg?.queue.ack !== false) {
|
|
393
|
+
const rest = state.queue.size > 0 ? `(后面还有 ${state.queue.size} 条)` : "";
|
|
394
|
+
void replyTo(api, state, job.openid, `开始执行队列 #${job.id}${rest}`);
|
|
395
|
+
}
|
|
396
|
+
startJob(pi, state, job);
|
|
397
|
+
} finally {
|
|
398
|
+
state.dispatching = false;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** 入队(忙碌时的统一入口):回执排队位置 */
|
|
403
|
+
async function enqueueJob(
|
|
404
|
+
api: QQApi,
|
|
405
|
+
state: State,
|
|
406
|
+
openid: string,
|
|
407
|
+
text: string,
|
|
408
|
+
): Promise<boolean> {
|
|
409
|
+
const result = state.queue.push(openid, text);
|
|
410
|
+
if (!result.ok) {
|
|
411
|
+
log("[queue] 队列已满(上限", state.queue.max, "),拒绝入队");
|
|
412
|
+
if (state.queue.max === 0) {
|
|
413
|
+
await replyTo(api, state, openid, "上一条消息还在处理中,请稍候…(队列已关闭:queue.max=0)");
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
await replyTo(
|
|
417
|
+
api,
|
|
418
|
+
state,
|
|
419
|
+
openid,
|
|
420
|
+
`队列已满(上限 ${state.queue.max} 条),这条没排上。发 /queue 查看队列,或 /cancel 清空后重发。`,
|
|
421
|
+
);
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
log("[queue] 入队 #", result.job.id, "size=", state.queue.size, "ahead=", result.ahead);
|
|
425
|
+
await replyTo(
|
|
426
|
+
api,
|
|
427
|
+
state,
|
|
428
|
+
openid,
|
|
429
|
+
`已排队 #${result.job.id}${result.ahead > 0 ? `(前面还有 ${result.ahead} 条)` : ""},当前任务结束后自动执行。发 /queue 查看队列,/cancel 可中止当前任务并清空队列。`,
|
|
430
|
+
);
|
|
431
|
+
return true;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function formatQueue(state: State): string {
|
|
435
|
+
const lines = [`当前任务:${state.busy ? "处理中(发 /cancel 可中断)" : "空闲"}`];
|
|
436
|
+
const jobs = state.queue.list();
|
|
437
|
+
if (jobs.length === 0) {
|
|
438
|
+
lines.push("队列:空");
|
|
439
|
+
return lines.join("\n");
|
|
440
|
+
}
|
|
441
|
+
lines.push(`队列:${jobs.length} 条(上限 ${state.queue.max})`);
|
|
442
|
+
jobs.slice(0, 10).forEach((job, index) => {
|
|
443
|
+
lines.push(` ${index + 1}. #${job.id} · ${agoLabel(job.enqueuedAt)} · ${previewText(job.text)}`);
|
|
444
|
+
});
|
|
445
|
+
if (jobs.length > 10) lines.push(` … 还有 ${jobs.length - 10} 条`);
|
|
446
|
+
lines.push("发 /queue clear 清空,/queue drop <编号> 删除一条。");
|
|
447
|
+
return lines.join("\n");
|
|
448
|
+
}
|
|
449
|
+
|
|
251
450
|
/**
|
|
252
|
-
*
|
|
451
|
+
* 用户发来取消指令:终止待审批、清空队列、abort 当前 run、丢弃半截回复,然后回执。
|
|
452
|
+
* 取消本身永不入队(“取消除外”),立即生效。
|
|
253
453
|
* (「取消自动同意」/「关闭自动同意」不属于取消指令,仍走审批分支。)
|
|
254
454
|
*/
|
|
255
455
|
async function handleCancel(
|
|
456
|
+
pi: ExtensionAPI,
|
|
256
457
|
state: State,
|
|
257
458
|
api: QQApi,
|
|
258
459
|
openid: string,
|
|
259
460
|
): Promise<void> {
|
|
260
461
|
const terminated = state.approver.terminateAll();
|
|
462
|
+
const dropped = state.queue.clear();
|
|
261
463
|
const running = state.busy || Boolean(state.ctx?.signal);
|
|
262
|
-
|
|
263
464
|
if (!running) {
|
|
465
|
+
const parts: string[] = [];
|
|
466
|
+
if (terminated > 0) parts.push(`已终止 ${terminated} 个待审批请求`);
|
|
467
|
+
if (dropped.length > 0) parts.push(`已清空队列(${dropped.length} 条)`);
|
|
264
468
|
await replyTo(
|
|
265
469
|
api,
|
|
266
470
|
state,
|
|
267
471
|
openid,
|
|
268
|
-
|
|
269
|
-
?
|
|
472
|
+
parts.length > 0
|
|
473
|
+
? `${parts.join(",")},当前没有正在运行的任务。`
|
|
270
474
|
: "当前没有正在运行的任务。",
|
|
271
475
|
);
|
|
272
476
|
return;
|
|
273
477
|
}
|
|
274
478
|
|
|
275
479
|
state.cancelled = true;
|
|
276
|
-
state.busy = false;
|
|
277
480
|
state.replyBuffer.reset();
|
|
278
481
|
state.currentOpenid = null; // 被中止的 run 不再往 QQ 发半截回复
|
|
279
482
|
const ctx = state.ctx;
|
|
280
483
|
state.ctx = null;
|
|
281
484
|
try {
|
|
282
485
|
ctx?.abort();
|
|
283
|
-
log("[cancel] 已请求中止当前 run,终止待审批", terminated, "
|
|
486
|
+
log("[cancel] 已请求中止当前 run,终止待审批", terminated, "个,清空队列", dropped.length, "条");
|
|
284
487
|
} catch (e) {
|
|
285
488
|
log("[cancel] abort 失败:", String(e));
|
|
286
489
|
}
|
|
490
|
+
// busy 保持 true 直到 agent_settled:期间新消息会排队,不会和正在收尾的 run 撞车
|
|
491
|
+
armCancelWatchdog(pi, state);
|
|
492
|
+
const notice: string[] = [];
|
|
493
|
+
if (terminated > 0) notice.push(`并终止了 ${terminated} 个待审批请求`);
|
|
494
|
+
if (dropped.length > 0) notice.push(`队列中的 ${dropped.length} 条已清空`);
|
|
287
495
|
await replyTo(
|
|
288
496
|
api,
|
|
289
497
|
state,
|
|
290
498
|
openid,
|
|
291
|
-
|
|
292
|
-
? `已取消当前任务,并终止了 ${terminated} 个待审批请求。可以发送新消息继续。`
|
|
293
|
-
: "已取消当前任务。可以发送新消息继续。",
|
|
499
|
+
`已取消当前任务${notice.length > 0 ? `,${notice.join(",")}` : ""}。可以发送新消息继续。`,
|
|
294
500
|
);
|
|
295
501
|
}
|
|
296
502
|
|
|
@@ -341,9 +547,9 @@ export async function handleMessage(
|
|
|
341
547
|
}
|
|
342
548
|
}
|
|
343
549
|
|
|
344
|
-
//
|
|
550
|
+
// 取消指令优先于审批解析:「取消」不该被当成审批动作;取消也永不入队
|
|
345
551
|
if (isCancelIntent(msg.content)) {
|
|
346
|
-
await handleCancel(state, api, msg.openid);
|
|
552
|
+
await handleCancel(pi, state, api, msg.openid);
|
|
347
553
|
return;
|
|
348
554
|
}
|
|
349
555
|
|
|
@@ -355,10 +561,30 @@ export async function handleMessage(
|
|
|
355
561
|
};
|
|
356
562
|
const routed = await routeSlash(
|
|
357
563
|
{
|
|
358
|
-
listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string }[],
|
|
359
|
-
dispatch: (text
|
|
564
|
+
listPiCommands: () => (pi.getCommands?.() ?? []) as { name: string; source?: string }[],
|
|
565
|
+
dispatch: (text, options) => {
|
|
566
|
+
// 提示模板 / /skill: 会开启一轮 agent run:和普通对话一样串行到队列
|
|
567
|
+
if (options?.runsTurn) {
|
|
568
|
+
if (!isBusy(state)) {
|
|
569
|
+
startJob(pi, state, state.queue.mint(msg.openid, text));
|
|
570
|
+
return "dispatched";
|
|
571
|
+
}
|
|
572
|
+
const queued = state.queue.push(msg.openid, text);
|
|
573
|
+
if (!queued.ok) {
|
|
574
|
+
log("[queue] 队列已满,命令未排队:", text.slice(0, 60));
|
|
575
|
+
return "rejected";
|
|
576
|
+
}
|
|
577
|
+
log("[queue] 命令入队 #", queued.job.id, text.slice(0, 60));
|
|
578
|
+
return "queued";
|
|
579
|
+
}
|
|
580
|
+
// 扩展命令 / 桥接命令(/qq …)不开 run,pi 允许流式期间执行,直接派发
|
|
581
|
+
void Promise.resolve(pi.sendUserMessage(text, { expandPromptTemplates: true })).catch(
|
|
582
|
+
(e) => log("[cmd] 派发失败:", String(e)),
|
|
583
|
+
);
|
|
584
|
+
return "dispatched";
|
|
585
|
+
},
|
|
360
586
|
reply: (text: string) => replyTo(api, state, msg.openid, text),
|
|
361
|
-
busy: state
|
|
587
|
+
busy: isBusy(state),
|
|
362
588
|
cfg: cfg.commands,
|
|
363
589
|
log,
|
|
364
590
|
},
|
|
@@ -421,60 +647,60 @@ export async function handleMessage(
|
|
|
421
647
|
|
|
422
648
|
// 内置指令
|
|
423
649
|
const cmd = parseCommand(msg.content);
|
|
424
|
-
if (cmd === "help") {
|
|
650
|
+
if (cmd?.name === "help") {
|
|
425
651
|
await replyTo(
|
|
426
652
|
api,
|
|
427
653
|
state,
|
|
428
654
|
msg.openid,
|
|
429
655
|
[
|
|
430
656
|
"pi-qqbot 指令:",
|
|
431
|
-
"/status — 查看状态(忙闲 / 自动同意 / 待审批)",
|
|
657
|
+
"/status — 查看状态(忙闲 / 队列 / 自动同意 / 待审批)",
|
|
658
|
+
"/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
|
|
432
659
|
"/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
|
|
433
|
-
"/cancel(或「取消」「停止」)—
|
|
660
|
+
"/cancel(或「取消」「停止」)— 中断正在运行的任务并清空队列",
|
|
434
661
|
"/help — 帮助",
|
|
435
662
|
"pi 的命令优先:/reload /new /compact /session /name /model /thinking,以及扩展命令、模板与 /skill:xxx。",
|
|
663
|
+
"忙碌时发来的普通消息(含模板/skill 命令)会排队,当前任务结束后自动执行。",
|
|
436
664
|
"其余文本交给 pi 当作对话处理。",
|
|
437
665
|
].join("\n"),
|
|
438
666
|
);
|
|
439
667
|
return;
|
|
440
668
|
}
|
|
441
|
-
if (cmd === "status") {
|
|
669
|
+
if (cmd?.name === "status") {
|
|
442
670
|
await replyTo(
|
|
443
671
|
api,
|
|
444
672
|
state,
|
|
445
673
|
msg.openid,
|
|
446
674
|
[
|
|
447
|
-
`状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : "空闲"}`,
|
|
675
|
+
`状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : state.queue.size > 0 ? "空闲,但有排队任务待派发" : "空闲"}`,
|
|
676
|
+
`队列: ${state.queue.size} 条待执行${state.queue.size > 0 ? "(发 /queue 查看)" : ""}`,
|
|
448
677
|
`自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
|
|
449
678
|
`待审批: ${state.approver.pendingCount}`,
|
|
450
679
|
].join("\n"),
|
|
451
680
|
);
|
|
452
681
|
return;
|
|
453
682
|
}
|
|
454
|
-
if (cmd === "
|
|
683
|
+
if (cmd?.name === "queue") {
|
|
684
|
+
await handleQueueCommand(api, state, msg.openid, cmd.args);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (cmd?.name === "reset") {
|
|
455
688
|
state.replyBuffer.reset();
|
|
456
689
|
state.approver.reset();
|
|
457
690
|
await replyTo(api, state, msg.openid, "已重置(回复缓冲/审批记忆/自动同意)。要开新会话请发 /new。");
|
|
458
691
|
return;
|
|
459
692
|
}
|
|
460
693
|
|
|
461
|
-
//
|
|
462
|
-
if (state
|
|
463
|
-
await
|
|
694
|
+
// 忙检查:单会话串行处理——忙碌时入队,当前任务结束后自动继续
|
|
695
|
+
if (isBusy(state)) {
|
|
696
|
+
await enqueueJob(api, state, msg.openid, msg.content);
|
|
464
697
|
return;
|
|
465
698
|
}
|
|
466
699
|
|
|
467
700
|
// ack
|
|
468
701
|
await replyTo(api, state, msg.openid, "收到,正在处理");
|
|
469
702
|
|
|
470
|
-
state.
|
|
471
|
-
state.busy = true;
|
|
472
|
-
state.cancelled = false;
|
|
473
|
-
state.replyBuffer.reset();
|
|
474
|
-
|
|
475
|
-
// 交给 pi 处理
|
|
476
|
-
pi.sendUserMessage(msg.content, { deliverAs: "followUp" });
|
|
477
|
-
log("[msg] 已送入 pi:", msg.content.slice(0, 60));
|
|
703
|
+
startJob(pi, state, state.queue.mint(msg.openid, msg.content));
|
|
478
704
|
} catch (e) {
|
|
479
705
|
log("[msg] 处理失败:", String(e));
|
|
480
706
|
await replyTo(api, state, msg.openid, `处理失败: ${String(e).slice(0, 200)}`).catch(() => {});
|
|
@@ -500,11 +726,53 @@ async function replyTo(
|
|
|
500
726
|
}
|
|
501
727
|
}
|
|
502
728
|
|
|
503
|
-
function parseCommand(text: string): "reset" | "status" | "help" | null {
|
|
729
|
+
function parseCommand(text: string): { name: "reset" | "status" | "help" | "queue"; args: string } | null {
|
|
504
730
|
// 注意:/new 现在属于 pi 的内置命令(由 slash 路由接管,真的开新会话),
|
|
505
731
|
// 本地的「清缓冲/清审批记忆」改名为 /reset。
|
|
506
|
-
const m = /^\/(reset|status|help)\s
|
|
507
|
-
|
|
732
|
+
const m = /^\/(reset|status|help|queue|q)(?:\s+([\s\S]*))?$/i.exec(text.trim());
|
|
733
|
+
if (!m) return null;
|
|
734
|
+
const raw = m[1].toLowerCase();
|
|
735
|
+
return {
|
|
736
|
+
name: (raw === "q" ? "queue" : raw) as "reset" | "status" | "help" | "queue",
|
|
737
|
+
args: (m[2] ?? "").trim(),
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** /queue [clear|drop <编号>] */
|
|
742
|
+
async function handleQueueCommand(api: QQApi, state: State, openid: string, args: string) {
|
|
743
|
+
const parts = args.split(/\s+/).filter(Boolean);
|
|
744
|
+
const sub = parts[0]?.toLowerCase() ?? "";
|
|
745
|
+
|
|
746
|
+
if (!sub) {
|
|
747
|
+
await replyTo(api, state, openid, formatQueue(state));
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (/^(clear|reset|清空)$/i.test(sub)) {
|
|
751
|
+
const dropped = state.queue.clear();
|
|
752
|
+
await replyTo(
|
|
753
|
+
api,
|
|
754
|
+
state,
|
|
755
|
+
openid,
|
|
756
|
+
dropped.length > 0 ? `已清空队列(${dropped.length} 条)。` : "队列本来就是空的。",
|
|
757
|
+
);
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (/^(drop|del|rm|删除|删|去掉)$/i.test(sub)) {
|
|
761
|
+
const id = Number.parseInt(parts[1] ?? "", 10);
|
|
762
|
+
if (!Number.isFinite(id)) {
|
|
763
|
+
await replyTo(api, state, openid, "用法:/queue drop <编号>(编号见 /queue)");
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const removed = state.queue.remove(id);
|
|
767
|
+
await replyTo(
|
|
768
|
+
api,
|
|
769
|
+
state,
|
|
770
|
+
openid,
|
|
771
|
+
removed ? `已从队列删除 #${id}:${previewText(removed.text)}` : `队列里没有 #${id},发 /queue 查看。`,
|
|
772
|
+
);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
await replyTo(api, state, openid, "用法:/queue 查看 / /queue clear 清空 / /queue drop <编号> 删一条");
|
|
508
776
|
}
|
|
509
777
|
|
|
510
778
|
/** 从 tool_call 事件提取可读摘要,让用户在 QQ 里看清要执行什么 */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zxh126/pi-qqbot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"./index.ts"
|
|
27
27
|
]
|
|
28
28
|
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node test/queue.test.mjs"
|
|
31
|
+
},
|
|
29
32
|
"files": [
|
|
30
33
|
"*.ts",
|
|
31
34
|
"qq",
|
package/queue.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ 侧任务队列。
|
|
3
|
+
*
|
|
4
|
+
* pi 的单会话是串行执行的:一轮 agent run 结束(agent_settled)后才可能开始下一轮。
|
|
5
|
+
* 于是忙碌期间收到的消息不再丢弃/拒绝,而是排队;当前任务结束后由 index.ts 的
|
|
6
|
+
* `drainQueue()` 逐条取出执行。
|
|
7
|
+
*
|
|
8
|
+
* 设计要点:
|
|
9
|
+
* - 全局单队列(本扩展是单会话设计),每条任务记住自己的 openid,回复发给原主人。
|
|
10
|
+
* - FIFO,编号单调递增不复用,方便用户用 `/queue drop <编号>` 定点删除。
|
|
11
|
+
* - `/cancel` 不走队列(立即生效),并且会清空队列。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface QueuedJob {
|
|
15
|
+
/** 队列编号,从 1 开始单调递增 */
|
|
16
|
+
id: number;
|
|
17
|
+
/** 任务来自哪个 QQ 用户 */
|
|
18
|
+
openid: string;
|
|
19
|
+
/** 交给 pi 的原始文本(普通对话 / 斜杠命令 / 提示模板 / skill 命令) */
|
|
20
|
+
text: string;
|
|
21
|
+
/** 入队时间戳(毫秒) */
|
|
22
|
+
enqueuedAt: number;
|
|
23
|
+
/** 派发失败的重试次数,由 index.ts 维护 */
|
|
24
|
+
attempts: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type PushResult =
|
|
28
|
+
| { ok: true; job: QueuedJob; ahead: number }
|
|
29
|
+
| { ok: false; reason: "full" };
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_QUEUE_MAX = 20;
|
|
32
|
+
|
|
33
|
+
/** 单条预览的最大字符数 */
|
|
34
|
+
const PREVIEW_MAX = 48;
|
|
35
|
+
|
|
36
|
+
export function previewText(text: string, max = PREVIEW_MAX): string {
|
|
37
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
38
|
+
return flat.length > max ? `${flat.slice(0, max)}…` : flat || "(空消息)";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 距离入队多久:`12s 前` / `3m 前` / `1h 前` */
|
|
42
|
+
export function agoLabel(at: number, now = Date.now()): string {
|
|
43
|
+
const seconds = Math.max(0, Math.round((now - at) / 1000));
|
|
44
|
+
if (seconds < 60) return `${seconds}s 前`;
|
|
45
|
+
const minutes = Math.floor(seconds / 60);
|
|
46
|
+
if (minutes < 60) return `${minutes}m 前`;
|
|
47
|
+
return `${Math.floor(minutes / 60)}h 前`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class MessageQueue {
|
|
51
|
+
private jobs: QueuedJob[] = [];
|
|
52
|
+
private nextId = 1;
|
|
53
|
+
private limit: number;
|
|
54
|
+
|
|
55
|
+
/** `limit = 0` 表示关闭排队(忙碌时直接拒绝新消息) */
|
|
56
|
+
constructor(limit = DEFAULT_QUEUE_MAX) {
|
|
57
|
+
this.limit = Math.max(0, Math.floor(limit) || 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 按配置调整上限;已有的任务不会被丢弃 */
|
|
61
|
+
setLimit(limit: number) {
|
|
62
|
+
this.limit = Math.max(0, Math.floor(limit) || 0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get size(): number {
|
|
66
|
+
return this.jobs.length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get max(): number {
|
|
70
|
+
return this.limit;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 只读快照(保持插入顺序) */
|
|
74
|
+
list(): readonly QueuedJob[] {
|
|
75
|
+
return this.jobs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
push(openid: string, text: string): PushResult {
|
|
79
|
+
if (this.limit === 0 || this.jobs.length >= this.limit) return { ok: false, reason: "full" };
|
|
80
|
+
const job: QueuedJob = {
|
|
81
|
+
id: this.nextId++,
|
|
82
|
+
openid,
|
|
83
|
+
text,
|
|
84
|
+
enqueuedAt: Date.now(),
|
|
85
|
+
attempts: 0,
|
|
86
|
+
};
|
|
87
|
+
this.jobs.push(job);
|
|
88
|
+
return { ok: true, job, ahead: this.jobs.length - 1 };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 手动构造一条不入队的任务(直接执行 / 失败重试时用) */
|
|
92
|
+
mint(openid: string, text: string): QueuedJob {
|
|
93
|
+
return { id: 0, openid, text, enqueuedAt: Date.now(), attempts: 0 };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
shift(): QueuedJob | undefined {
|
|
97
|
+
return this.jobs.shift();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 派发失败时放回队首重试 */
|
|
101
|
+
unshift(job: QueuedJob): void {
|
|
102
|
+
this.jobs.unshift(job);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 按编号删除一条 */
|
|
106
|
+
remove(id: number): QueuedJob | undefined {
|
|
107
|
+
const index = this.jobs.findIndex((job) => job.id === id);
|
|
108
|
+
if (index === -1) return undefined;
|
|
109
|
+
return this.jobs.splice(index, 1)[0];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 清空并返回被清掉的任务 */
|
|
113
|
+
clear(): QueuedJob[] {
|
|
114
|
+
const dropped = this.jobs;
|
|
115
|
+
this.jobs = [];
|
|
116
|
+
return dropped;
|
|
117
|
+
}
|
|
118
|
+
}
|
package/slash.ts
CHANGED
|
@@ -114,8 +114,13 @@ export const DEFAULT_COMMANDS: CommandConfig = {
|
|
|
114
114
|
export interface RouteDeps {
|
|
115
115
|
/** commands pi currently knows about (extension/prompt/skill) */
|
|
116
116
|
listPiCommands(): readonly { name: string; source?: string }[];
|
|
117
|
-
/**
|
|
118
|
-
|
|
117
|
+
/**
|
|
118
|
+
* deliver a pi command through pi's own dispatcher.
|
|
119
|
+
* `runsTurn` marks commands that start an agent run (prompt templates, `/skill:name`):
|
|
120
|
+
* those are serialized through the bot's queue, everything else runs immediately.
|
|
121
|
+
* Returns "queued" when the bot deferred it, "rejected" when the queue was full.
|
|
122
|
+
*/
|
|
123
|
+
dispatch(text: string, options?: { runsTurn?: boolean }): DispatchResult;
|
|
119
124
|
/** answer the QQ user */
|
|
120
125
|
reply(text: string): Promise<void>;
|
|
121
126
|
busy: boolean;
|
|
@@ -123,6 +128,8 @@ export interface RouteDeps {
|
|
|
123
128
|
log(message: string, ...rest: unknown[]): void;
|
|
124
129
|
}
|
|
125
130
|
|
|
131
|
+
export type DispatchResult = "dispatched" | "queued" | "rejected";
|
|
132
|
+
|
|
126
133
|
export type RouteResult = "dispatched" | "handled" | "text";
|
|
127
134
|
|
|
128
135
|
const allowed = (cfg: CommandConfig, name: string): boolean => {
|
|
@@ -139,11 +146,19 @@ export async function routeSlash(deps: RouteDeps, text: string): Promise<RouteRe
|
|
|
139
146
|
|
|
140
147
|
// 1. pi extension commands, prompt templates and skill commands
|
|
141
148
|
if (deps.cfg.passthrough) {
|
|
142
|
-
const known = deps.listPiCommands().
|
|
149
|
+
const known = deps.listPiCommands().find((c) => c.name.toLowerCase() === name);
|
|
143
150
|
if (known) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
151
|
+
// 提示模板与 /skill: 会开启一轮 agent run,交给队列串行;扩展命令自己管理 LLM 交互
|
|
152
|
+
const runsTurn = known.source === "prompt" || known.source === "skill";
|
|
153
|
+
deps.log("[cmd] 派发 pi 命令:", parsed.raw.slice(0, 80), "runsTurn=", runsTurn);
|
|
154
|
+
const result = deps.dispatch(parsed.raw, { runsTurn });
|
|
155
|
+
if (result === "queued") {
|
|
156
|
+
await deps.reply(`已排队 /${name}:当前任务结束后执行(发 /queue 查看队列)。`);
|
|
157
|
+
} else if (result === "rejected") {
|
|
158
|
+
await deps.reply(`队列已满,/${name} 没有排上。发 /queue 查看队列,或 /cancel 清空后重发。`);
|
|
159
|
+
} else {
|
|
160
|
+
await deps.reply(`已派发 /${name} 给 pi。`);
|
|
161
|
+
}
|
|
147
162
|
return "dispatched";
|
|
148
163
|
}
|
|
149
164
|
}
|