@zxh126/pi-qqbot 2.2.1 → 2.3.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/CHANGELOG.md +39 -0
- package/README.md +39 -2
- package/config.ts +9 -0
- package/docs/README.zh-CN.md +35 -2
- package/index.ts +110 -2
- package/package.json +2 -2
- package/remote.ts +81 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,45 @@
|
|
|
3
3
|
本文件记录 pi-qqbot 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
|
4
4
|
版本号遵循 [SemVer](https://semver.org/lang/zh-CN/)。
|
|
5
5
|
|
|
6
|
+
## [2.3.0] - 2026-10-31
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **远程上下文提醒**:`before_agent_start` 里先判断「这一轮是不是本扩展刚派发的 QQ 消息」,是才把一段说明写进
|
|
11
|
+
系统提示的 `qq-remote-context` section(排在用户消息之前,QQ 侧与 TUI 都看不到):说明对话来自远程 QQ 用户、
|
|
12
|
+
对方看不到本机屏幕与弹窗;默认**禁用桌面截屏 / 屏幕 OCR / UI 自动化 / 键鼠模拟 / 带界面的浏览器**
|
|
13
|
+
(点名 `mcp__screenshot`、`mcp__winui`),需要图就离屏渲染生成 PNG 再用 `qq_send_file` 发回,
|
|
14
|
+
只有确认是否锁屏时才允许截一次桌面。**仅仅载入扩展、或本机 TUI 直接对话时都不注入**,必须等第一条 QQ 消息真的开跑。
|
|
15
|
+
- **系统提示规则**:每次**来自 QQ 的** run 追加三条短规则(远程用户 / 锁屏→离屏渲染 /
|
|
16
|
+
需要人工就直说),避免长会话被压缩摘要后丢失;同一轮重复触发不叠加,本机 TUI 自己的 run 不加。
|
|
17
|
+
- **压缩后重注入**:`session_compact` 之后清掉「已提醒」标记,下一条消息重新注入。
|
|
18
|
+
- **配置 `remote`**:`notice`(默认 `true`,每会话一次)、`guidelines`(默认 `true`)、`extra`(追加到提醒末尾,
|
|
19
|
+
默认空);顶层默认值 + 每个 bot 逐键覆盖。
|
|
20
|
+
- **本机接管更正**:同一个会话里「上一轮来自 QQ、下一轮本机 TUI 直接打字」时,本机那一轮注入一次
|
|
21
|
+
`qq-local-handoff` section(「远程 QQ 用户」前提作废:可正常截屏、不需要 `qq_send_file`),连续本机对话不重复;
|
|
22
|
+
用 `input` 事件的 `source=interactive` 识别(本扩展的 `sendUserMessage` 是 `extension`,不会误判);
|
|
23
|
+
QQ run 跑到一半时插话不算接管(没有新的 run)。`/status` 新增「来源: QQ(远程)/ 本机(TUI)」。
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **等审批期间不再推自动进度**:有工具审批在等用户回复时,自动进度推送暂缓(否则每 15s 一条进度会把审批提示
|
|
28
|
+
顶出屏幕),审批处理完(或超时)后下一拍自动恢复;主动问 `/progress` 不受影响;`/status` 会显示
|
|
29
|
+
「等审批期间暂缓」。本会话内手动暂停(`/progress off`)与配额静默的语义不变。
|
|
30
|
+
|
|
31
|
+
### Tests
|
|
32
|
+
|
|
33
|
+
- 新增 `test/remote-notice.test.mjs`(默认值 / 文案要素 / `extra` 追加 / 系统规则长度)。
|
|
34
|
+
- `test/config.test.mjs` 新增 `remote` 全局默认 + 账号逐键覆盖用例。
|
|
35
|
+
- `test/accounts.test.mjs` 新增 3 个用例:**本机直接对话不注入**、第一条 QQ 消息的 run 才注入且每会话一次、
|
|
36
|
+
压缩后重注入;`remote.notice=false` 只关说明、`remote.extra` 追加;`remote.guidelines=false` 只关规则,
|
|
37
|
+
且同一轮重复触发不叠加。
|
|
38
|
+
- `test/progress.test.mjs` 新增用例:等审批期间不推送、`/status` 显示暂缓、手动问进度仍回一次、审批处理完自动恢复。
|
|
39
|
+
- `test/accounts.test.mjs` 再加 2 个用例:本机接管只更正一次(`/status` 显示来源);交接待定会被新的 QQ 轮覆盖,
|
|
40
|
+
`source=extension` 的 input 不算接管。
|
|
41
|
+
- `test/remote-notice.test.mjs` 加 `qq-local-handoff` 的 tag 与文案断言。
|
|
42
|
+
|
|
43
|
+
## [Unreleased]
|
|
44
|
+
|
|
6
45
|
## [2.2.1] - 2026-02-11
|
|
7
46
|
|
|
8
47
|
### Fixed
|
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ Bridge the **[Pi](https://pi.dev) coding agent** into QQ private (C2C) chats: se
|
|
|
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
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. |
|
|
15
15
|
| **Remote approval** | Before pi runs `bash` / `powershell` / `write` / `edit` — or any tool annotated `destructiveHint` — the bot asks in QQ first. |
|
|
16
|
+
| **Remote-context notice** | The first QQ message of a session is really handed to pi (loading the extension or chatting locally in the TUI adds nothing), then the bot tells it the conversation comes from a remote QQ user and that the machine may be locked: desktop screenshots / screen OCR / UI automation / headed browsers are off by default, images must be rendered off-screen and sent with `qq_send_file`. Re-injected after context compaction. |
|
|
16
17
|
| **Session-scoped "always allow"** | Reply 「以后都同意」 and every approval request in the **current session** is auto-approved, including the one already waiting. |
|
|
17
18
|
| **Single-instance lock** | Only one pi process hosts the QQ gateway; the others stand by and take over automatically when the host exits. |
|
|
18
19
|
| **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. |
|
|
@@ -64,6 +65,11 @@ Then create `~/.pi/agent/pi-qqbot.json`:
|
|
|
64
65
|
"files": {
|
|
65
66
|
"enabled": true,
|
|
66
67
|
"maxBytes": 31457280
|
|
68
|
+
},
|
|
69
|
+
"remote": {
|
|
70
|
+
"notice": true,
|
|
71
|
+
"guidelines": true,
|
|
72
|
+
"extra": ""
|
|
67
73
|
}
|
|
68
74
|
}
|
|
69
75
|
```
|
|
@@ -90,6 +96,9 @@ Then create `~/.pi/agent/pi-qqbot.json`:
|
|
|
90
96
|
| `attachments.maxFiles` | `200` | Keep at most this many files in the directory (oldest are deleted) |
|
|
91
97
|
| `files.enabled` | `true` | Allow sending local files to QQ (both the tool and `/sendfile`); `false` refuses |
|
|
92
98
|
| `files.maxBytes` | `31457280` | Per-file limit for sending (30 MB; QQ hard limit is 200 MB) |
|
|
99
|
+
| `remote.notice` | `true` | Inject a one-time "this is a remote QQ user" note before the first message handed to pi in a session (QQ-side invisible; re-injected after compaction) |
|
|
100
|
+
| `remote.guidelines` | `true` | Also append three short rules to the system prompt of every run started from QQ (remote user, locked screen → off-screen rendering, ask instead of waiting) |
|
|
101
|
+
| `remote.extra` | `""` | Extra text appended to the end of the built-in notice |
|
|
93
102
|
|
|
94
103
|
Credentials can also come from the `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` environment variables (single-bot setup only).
|
|
95
104
|
|
|
@@ -117,7 +126,7 @@ Start pi, then message the bot in QQ; pi handles it and replies.
|
|
|
117
126
|
| Command | Description |
|
|
118
127
|
|---|---|
|
|
119
128
|
| `/reset` | Clear the reply buffer, approval memory and session-wide auto-approve (**does not** switch sessions — send `/new` for that) |
|
|
120
|
-
| `/status` | Show the bot this process serves (name + working directory), busy/idle state, queue length, auto-approve state, pending approval count and the progress-report mode |
|
|
129
|
+
| `/status` | Show the bot this process serves (name + working directory), busy/idle state, origin of the last turn (QQ / local TUI), queue length, auto-approve state, pending approval count and the progress-report mode |
|
|
121
130
|
| `/queue` | Show the queue (aliases: `/q`); `/queue clear` empties it, `/queue drop <id>` removes one entry |
|
|
122
131
|
| `/progress` | **Show the current progress summary immediately** (aliases: `/p`, 「进度」, 「当前进度」, 「进展」; plain-text questions like 「到哪了」「怎么样了」 work while busy). While you paused reports yourself it only replies once — use `/progress on` to resume; while reports are merely quota-quiet, asking resumes them |
|
|
123
132
|
| `/progress off` | **Pause automatic progress reports for the rest of the session** (aliases: `pause`, `stop`, `暂停`, 「暂停汇报」, 「关闭汇报」). Manual `/progress` still works, it just no longer resumes the timer |
|
|
@@ -189,6 +198,31 @@ Notes:
|
|
|
189
198
|
- Limits on QQ's side: 200 MB hard cap and a **daily total file quota** (`40093002`); the bot turns both into readable replies.
|
|
190
199
|
- The reply also tells you what was sent: `已发送「报告.pdf」(2.31 MB,文件卡片)`.
|
|
191
200
|
|
|
201
|
+
### Remote-context notice
|
|
202
|
+
|
|
203
|
+
pi runs on the machine, you are in QQ — and that machine may be locked. So the first time a QQ message of a session is
|
|
204
|
+
actually handed to pi, the bot adds a short note to the **system prompt** (a `qq-remote-context` section, so it sits
|
|
205
|
+
before the user message and QQ never sees it) that says:
|
|
206
|
+
|
|
207
|
+
- the user is remote: no screen, no dialogs, keep replies concise;
|
|
208
|
+
- desktop screenshots, screen OCR, UI automation, keyboard/mouse simulation and headed browsers are off by default
|
|
209
|
+
(`mcp__screenshot`, `mcp__winui` are named explicitly) — on a locked machine they only capture the lock screen or fail;
|
|
210
|
+
- when an image is needed, render it off-screen (headless browser over HTML, SVG, matplotlib/Pillow, …) and send the PNG
|
|
211
|
+
with `qq_send_file` instead of trying to capture the desktop;
|
|
212
|
+
- capturing the desktop once is allowed only to check whether the machine is locked; everything else stays off-screen.
|
|
213
|
+
|
|
214
|
+
Timing matters: the note is only added from `before_agent_start` for a run this extension dispatched, so merely loading
|
|
215
|
+
the extension — or chatting with pi directly in the TUI on the host — sends nothing. It appears once per session (again
|
|
216
|
+
after context compaction; `remote.extra` appends your own lines). With `remote.guidelines` the same expectations are
|
|
217
|
+
condensed into three bullets that ride along in the system prompt of every run that came from QQ, so they survive
|
|
218
|
+
summarisation. Set `remote.notice: false` / `remote.guidelines: false` to turn either part off.
|
|
219
|
+
|
|
220
|
+
If you take over the same session from the host — turn N from QQ, turn N+1 typed in the TUI — the next local run gets a
|
|
221
|
+
one-time `qq-local-handoff` section instead: the remote/locked-screen premise is void (screenshots are fine, no
|
|
222
|
+
`qq_send_file` needed). Detection uses the `input` event (`source: "interactive"`; the bot's own `sendUserMessage` is
|
|
223
|
+
`"extension"`, so it never misfires), it only fires once per hand-off, and it is skipped while a QQ run is still
|
|
224
|
+
streaming (no new run starts then). `/status` shows `来源: QQ(远程)` or `来源: 本机(TUI)`.
|
|
225
|
+
|
|
192
226
|
### Slash commands from QQ
|
|
193
227
|
|
|
194
228
|
Any message that starts with `/` is routed before it can become a chat prompt:
|
|
@@ -306,7 +340,10 @@ While a run is in progress, a summary is pushed to QQ every `progress.intervalMs
|
|
|
306
340
|
- **Manual pause / resume**: `/progress off` (or 「暂停汇报」) silences the reports for the rest of the
|
|
307
341
|
session — it survives `agent_settled` and the next run, and asking for `/progress` no longer turns it
|
|
308
342
|
back on. `/progress on` (or 「恢复汇报」) resumes. A new session (`/new`, restart) starts enabled again.
|
|
309
|
-
`/status` shows which of the
|
|
343
|
+
`/status` shows which of the five modes you are in (on / paused-by-you / quota-quiet / held-during-approval / disabled).
|
|
344
|
+
- **Approvals stay visible**: while a tool approval is waiting for your reply, automatic reports are held back — otherwise
|
|
345
|
+
a 15 s progress line would keep pushing the prompt off your screen. They resume on the next interval after you answer
|
|
346
|
+
(or the approval times out). Asking for `/progress` yourself still works.
|
|
310
347
|
- **The final answer is protected**: if it cannot be delivered (quota), it is cached and re-sent with a
|
|
311
348
|
`(上一条回复发送失败,补发如下)` prefix as soon as you send the next message.
|
|
312
349
|
- Timer is cleared on `agent_settled` / `/cancel` / session shutdown.
|
package/config.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
DEFAULT_SEND_MAX_BYTES,
|
|
9
9
|
} from "./constants";
|
|
10
10
|
import { DEFAULT_COMMANDS, type CommandConfig } from "./slash";
|
|
11
|
+
import { DEFAULT_REMOTE, type RemoteConfig } from "./remote";
|
|
11
12
|
import { DEFAULT_QUEUE_MAX } from "./queue";
|
|
12
13
|
|
|
13
14
|
export interface QueueConfig {
|
|
@@ -65,6 +66,8 @@ export interface QQBotConfig {
|
|
|
65
66
|
attachments: AttachmentConfig;
|
|
66
67
|
/** 把本机文件发到 QQ */
|
|
67
68
|
files: FilesConfig;
|
|
69
|
+
/** 「以下对话来自远程用户」提醒与系统提示规则 */
|
|
70
|
+
remote: RemoteConfig;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export interface FilesConfig {
|
|
@@ -96,6 +99,7 @@ export interface QQBotsEntry {
|
|
|
96
99
|
progress?: Partial<ProgressConfig>;
|
|
97
100
|
attachments?: Partial<AttachmentConfig>;
|
|
98
101
|
files?: Partial<FilesConfig>;
|
|
102
|
+
remote?: Partial<RemoteConfig>;
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/** 校验并归一化后的账号(凭据 + 合并好的完整配置) */
|
|
@@ -187,6 +191,11 @@ export function resolveConfig(
|
|
|
187
191
|
enabled: entry.files?.enabled ?? file.files?.enabled ?? true,
|
|
188
192
|
maxBytes: Number(entry.files?.maxBytes ?? file.files?.maxBytes ?? DEFAULT_SEND_MAX_BYTES),
|
|
189
193
|
},
|
|
194
|
+
remote: {
|
|
195
|
+
notice: entry.remote?.notice ?? file.remote?.notice ?? DEFAULT_REMOTE.notice,
|
|
196
|
+
guidelines: entry.remote?.guidelines ?? file.remote?.guidelines ?? DEFAULT_REMOTE.guidelines,
|
|
197
|
+
extra: String(entry.remote?.extra ?? file.remote?.extra ?? DEFAULT_REMOTE.extra).trim(),
|
|
198
|
+
},
|
|
190
199
|
};
|
|
191
200
|
}
|
|
192
201
|
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
| **对话驱动 agent** | QQ 单聊消息送入当前 pi 会话,AI 的回复发回 QQ(长文本自动分片)。 |
|
|
14
14
|
| **消息队列** | 正在处理任务时,后续 QQ 消息会被**排队**而不是被拒;当前任务结束(settled)后按到达顺序自动逐条执行。`/cancel` 是例外——立即生效并清空队列。 |
|
|
15
15
|
| **远程审批** | pi 执行 `bash` / `powershell` / `write` / `edit`,或任何带 `destructiveHint` 注解的工具前,先在 QQ 里申请审批。 |
|
|
16
|
+
| **远程上下文提醒** | **每个会话第一次真正把 QQ 消息交给 pi 跑起来**时,才告诉它「对话来自远程 QQ 用户、本机可能锁屏」:桌面截屏 / 屏幕 OCR / UI 自动化 / 带界面的浏览器默认禁用,要图就离屏渲染再用 `qq_send_file` 发回。仅载入扩展、或本机 TUI 直接对话时都不注入;上下文压缩后自动重新注入。 |
|
|
16
17
|
| **会话级「以后都同意」** | 回复「以后都同意」,**当前会话**内所有审批请求直接放行,连已经挂起的那条也一起放行。 |
|
|
17
18
|
| **单实例锁** | 只有一个 pi 进程作为宿主连接 QQ,其余待命,宿主退出后自动接管。 |
|
|
18
19
|
| **中断正在运行的任务** | 发 `/cancel`(或「取消」/「停止」),当前任务立刻中断 —— QQ 回复、待审批工具调用、写到一半的回答、排队中的消息一并清理。 |
|
|
@@ -64,6 +65,11 @@ pi install ./pi-qqbot
|
|
|
64
65
|
"files": {
|
|
65
66
|
"enabled": true,
|
|
66
67
|
"maxBytes": 31457280
|
|
68
|
+
},
|
|
69
|
+
"remote": {
|
|
70
|
+
"notice": true,
|
|
71
|
+
"guidelines": true,
|
|
72
|
+
"extra": ""
|
|
67
73
|
}
|
|
68
74
|
}
|
|
69
75
|
```
|
|
@@ -90,6 +96,9 @@ pi install ./pi-qqbot
|
|
|
90
96
|
| `attachments.maxFiles` | `200` | 目录内最多保留多少个文件(超出删最旧) |
|
|
91
97
|
| `files.enabled` | `true` | 是否允许把本机文件发到 QQ(工具与 `/sendfile` 共用);`false` 则拒绕 |
|
|
92
98
|
| `files.maxBytes` | `31457280` | 发送单个文件的上限(30MB;QQ 硬限 200MB) |
|
|
99
|
+
| `remote.notice` | `true` | 每会话第一条消息交给 pi 前注入一次「对话来自远程用户」的说明(QQ 侧不可见,压缩后重新注入) |
|
|
100
|
+
| `remote.guidelines` | `true` | 每次来自 QQ 的 run 额外往系统提示里加三条短规则(远程用户、锁屏→离屏渲染、需要人工就直说) |
|
|
101
|
+
| `remote.extra` | `""` | 追加到内置提醒末尾的自定义补充 |
|
|
93
102
|
|
|
94
103
|
也可用环境变量 `QQ_BOT_APPID` / `QQ_BOT_APPSECRET` 代替文件里的凭据(仅单 bot 场景)。
|
|
95
104
|
|
|
@@ -115,7 +124,7 @@ pi install ./pi-qqbot
|
|
|
115
124
|
| 指令 | 说明 |
|
|
116
125
|
|---|---|
|
|
117
126
|
| `/reset` | 重置:清空回复缓冲、审批记忆与自动同意开关(**不换会话**,换会话发 `/new`) |
|
|
118
|
-
| `/status` | 查看本进程服务的 bot(名称 +
|
|
127
|
+
| `/status` | 查看本进程服务的 bot(名称 + 工作目录)、忙闲状态、上一轮来源(QQ / 本机 TUI)、队列长度、自动同意状态、待审批数量、自动汇报模式 |
|
|
119
128
|
| `/queue` | 查看队列(别名 `/q`);`/queue clear` 清空,`/queue drop <编号>` 删一条 |
|
|
120
129
|
| `/progress` | **立即获取当前进度摘要**(别名 `/p`、「进度」「当前进度」「进展」;忙碌时「到哪了」「怎么样了」这类问法同样生效)。手动暂停期间**问一次只回一次**,要重新打开定时汇报需发 `/progress on` 或「恢复汇报」;若只是配额静默,问一次即恢复 |
|
|
121
130
|
| `/progress off` | **在本会话内暂停自动进度汇报**(别名:`pause`、`stop`、`暂停`、「暂停汇报」、「关闭汇报」)。手动 `/progress` 仍可看一次,但不再解除暂停 |
|
|
@@ -181,6 +190,28 @@ pi install ./pi-qqbot
|
|
|
181
190
|
- QQ 侧还有 **200MB 硬上限**和**每天发送文件总容量上限**(错误码 `40093002`),都翻成中文回执。
|
|
182
191
|
- 成功后会回一行:`已发送「报告.pdf」(2.31 MB,文件卡片)`。
|
|
183
192
|
|
|
193
|
+
### 远程上下文提醒
|
|
194
|
+
|
|
195
|
+
pi 跑在这台机器上,用户在 QQ 那头——而这台机器可能锁屏。所以**每个会话第一次真正把 QQ 消息交给 pi 跑起来**时,
|
|
196
|
+
插件会往**系统提示**里加一段说明(`qq-remote-context` section,排在用户消息之前,QQ 侧看不到),告诉它:
|
|
197
|
+
|
|
198
|
+
- 用户是远程的:看不见屏幕、也点不到弹窗,回复应简明扼要;
|
|
199
|
+
- 桌面截屏、屏幕 OCR、UI 自动化、键鼠模拟、带界面的浏览器默认都不要用(文案里点名了 `mcp__screenshot`、`mcp__winui`),
|
|
200
|
+
锁屏下它们只会拍到锁屏画面或直接失败;
|
|
201
|
+
- 需要图时自己离屏渲染(headless 浏览器渲 HTML、SVG、matplotlib/Pillow…),生成 PNG 再用 `qq_send_file` 发到 QQ,
|
|
202
|
+
不要为了「确认」先截桌面;
|
|
203
|
+
- 只有确实需要判断是否锁屏时,才允许截一次桌面看有没有内容。
|
|
204
|
+
|
|
205
|
+
时机很关键:只有本扩展派发的 QQ 消息真的开跑(`before_agent_start`)时才会注入——**仅仅载入扩展、或者本机在 TUI 里
|
|
206
|
+
直接跟 pi 对话,都不会给 pi 发任何东西**。每个会话注入一次(上下文压缩后重新注入;`remote.extra` 可追加你自己的话)。
|
|
207
|
+
开了 `remote.guidelines` 时,同样的要求会压成三条规则,随每次来自 QQ 的 run 一起进系统提示,避免长会话被摘要后丢失。
|
|
208
|
+
`remote.notice: false` / `remote.guidelines: false` 可分别关掉这两部分。
|
|
209
|
+
|
|
210
|
+
如果同一个会话里你从本机接管——上一轮来自 QQ,下一轮直接在 TUI 里打字——本机那一轮会换成一次性
|
|
211
|
+
`qq-local-handoff` 更正:「远程 QQ 用户」的前提作废(可以正常截屏,不需要 `qq_send_file`)。识别靠 `input` 事件
|
|
212
|
+
(`source: "interactive"`;本扩展自己的 `sendUserMessage` 是 `"extension"`,不会误判),每次交接只更正一次,
|
|
213
|
+
QQ run 还在跑时插话不处理(那时不会开新的 run)。`/status` 会显示 `来源: QQ(远程)` 或 `来源: 本机(TUI)`。
|
|
214
|
+
|
|
184
215
|
### QQ 里的斜杠命令
|
|
185
216
|
|
|
186
217
|
任何以 `/` 开头的消息都会先走路由,再决定是否当成对话:
|
|
@@ -287,7 +318,9 @@ pi 的会话一次只跑一轮 agent run,所以串行调度由 bot 自己做
|
|
|
287
318
|
的自动推送(不重试、不刷屏);你发 `/progress`(或「进度」)即恢复。
|
|
288
319
|
- **手动暂停/恢复**:`/progress off`(或「暂停汇报」)在本会话内停掉自动推送——跨 run 保留,
|
|
289
320
|
之后问 `/progress` 也不会把它打开;`/progress on`(或「恢复汇报」)恢复。开新会话(`/new`、重启)后
|
|
290
|
-
恢复默认开启。`/status` 会显示当前处于哪种模式(开启 / 手动暂停 / 配额静默 / 配置关闭)。
|
|
321
|
+
恢复默认开启。`/status` 会显示当前处于哪种模式(开启 / 手动暂停 / 配额静默 / 等审批暂缓 / 配置关闭)。
|
|
322
|
+
- **审批不被顶走**:有工具审批在等你回复时,自动进度暂缓推送——否则每 15 秒一条进度会把审批提示顶出屏幕;
|
|
323
|
+
你回复(或审批超时)后,下一拍自动恢复。主动发 `/progress` 照旧可用。
|
|
291
324
|
- **保护最终答案**:答案因配额发不出去时会被缓存,等你发下一条消息时以
|
|
292
325
|
`(上一条回复发送失败,补发如下)` 前缀补发。
|
|
293
326
|
- `agent_settled` / `/cancel` / 会话关闭时定时器都会清掉。
|
package/index.ts
CHANGED
|
@@ -39,6 +39,14 @@ import { Approver } from "./approver";
|
|
|
39
39
|
import { parsePickIndex, pickerExpired, routeSlash, runBridge, type PickerState } from "./slash";
|
|
40
40
|
import { agoLabel, MessageQueue, previewText, type QueuedJob } from "./queue";
|
|
41
41
|
import { ProgressTracker } from "./progress";
|
|
42
|
+
import {
|
|
43
|
+
buildLocalHandoffNotice,
|
|
44
|
+
buildRemoteGuidelines,
|
|
45
|
+
buildRemoteNotice,
|
|
46
|
+
LOCAL_HANDOFF_SECTION,
|
|
47
|
+
REMOTE_NOTICE_SECTION,
|
|
48
|
+
resolveRemoteConfig,
|
|
49
|
+
} from "./remote";
|
|
42
50
|
import { redactSecrets } from "./redact";
|
|
43
51
|
import { ReplyBuffer } from "./stream-buffer";
|
|
44
52
|
import { splitText } from "./util/chunk";
|
|
@@ -93,6 +101,14 @@ interface State {
|
|
|
93
101
|
progressPaused: boolean;
|
|
94
102
|
/** 用户手动暂停自动推送(本会话有效:跨 run 保留,/progress on 恢复) */
|
|
95
103
|
progressMuted: boolean;
|
|
104
|
+
/** 等审批期间暂缓自动进度推送(避免把审批消息顶走);审批处理完自动恢复 */
|
|
105
|
+
progressHeldForApproval: boolean;
|
|
106
|
+
/** 本会话是否已给 pi 注入过「远程用户」提醒(每会话一次,压缩后重新注入) */
|
|
107
|
+
remoteNotified: boolean;
|
|
108
|
+
/** 上一轮 run 的来源:null = 还没跑过 */
|
|
109
|
+
lastRunOrigin: "qq" | "local" | null;
|
|
110
|
+
/** 本机在 TUI 接管了对话,等下一轮 run 开跑时更正「远程前提作废」 */
|
|
111
|
+
localHandoffPending: boolean;
|
|
96
112
|
/** 最终答案发送失败时缓存,下一条消息时补发 */
|
|
97
113
|
undelivered: { openid: string; text: string } | null;
|
|
98
114
|
/** 附件下载 401/403 时重试用的凭据(接入后由 applyClaim 注入) */
|
|
@@ -130,6 +146,10 @@ export function createInitialState(): State {
|
|
|
130
146
|
progressTimer: null,
|
|
131
147
|
progressPaused: false,
|
|
132
148
|
progressMuted: false,
|
|
149
|
+
progressHeldForApproval: false,
|
|
150
|
+
remoteNotified: false,
|
|
151
|
+
lastRunOrigin: null,
|
|
152
|
+
localHandoffPending: false,
|
|
133
153
|
undelivered: null,
|
|
134
154
|
getToken: null,
|
|
135
155
|
attachmentFetch: null,
|
|
@@ -153,6 +173,59 @@ export default function (
|
|
|
153
173
|
) {
|
|
154
174
|
const state: State = options?.state ?? createInitialState();
|
|
155
175
|
|
|
176
|
+
// ---- 事件:本机用户在 TUI 直接打字(source=interactive)→ 记一笔「可能接管」----
|
|
177
|
+
// 只记待定,真正注入要等下一轮 run 开跑(before_agent_start)。
|
|
178
|
+
// 本扩展自己的 sendUserMessage 是 source=extension,不会走到这里;QQ run 跑着时接管不处理。
|
|
179
|
+
pi.on("input", (event) => {
|
|
180
|
+
const ev = event as any;
|
|
181
|
+
if (ev?.source !== "interactive") return;
|
|
182
|
+
if (state.busy) return;
|
|
183
|
+
if (state.lastRunOrigin !== "qq") return;
|
|
184
|
+
if (state.localHandoffPending) return;
|
|
185
|
+
state.localHandoffPending = true;
|
|
186
|
+
log("[remote] 检测到本机 TUI 输入,下一轮按「本机用户」前提处理");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ---- 事件:真正开始一轮 run 时,按「来自 QQ 还是本机」准备系统提示 ----
|
|
190
|
+
// 不在扩展载入时注入,也不在本机 TUI 自己对话时注入:只有 startJob() 派发的
|
|
191
|
+
// QQ 消息真的开跑(state.busy + currentOpenid)才会走到这里。
|
|
192
|
+
pi.on("before_agent_start", (event) => {
|
|
193
|
+
const cfg = state.cfg;
|
|
194
|
+
if (!cfg) return;
|
|
195
|
+
if ((event as any).messageType !== "user") return;
|
|
196
|
+
const options = (event as any).systemPromptOptions;
|
|
197
|
+
if (!options || typeof options !== "object") return;
|
|
198
|
+
|
|
199
|
+
// 本扩展派发的 QQ 消息
|
|
200
|
+
if (state.busy && state.currentOpenid) {
|
|
201
|
+
const remote = resolveRemoteConfig(cfg);
|
|
202
|
+
if (remote.guidelines && Array.isArray(options.promptGuidelines)) {
|
|
203
|
+
const guidelines = buildRemoteGuidelines();
|
|
204
|
+
// 同一次 run 可能重复触发(如压缩后重试),别叠加两遍
|
|
205
|
+
if (!options.promptGuidelines.includes(guidelines[0])) options.promptGuidelines.push(...guidelines);
|
|
206
|
+
}
|
|
207
|
+
if (remote.notice && !state.remoteNotified) {
|
|
208
|
+
state.remoteNotified = true;
|
|
209
|
+
// 放进系统提示的 section:排在用户消息之前,模型不会把它当成要回复的消息;
|
|
210
|
+
// 只在本轮生效,操作规则由 guidelines 继续带在后续每一轮。
|
|
211
|
+
options.sections = { ...(options.sections ?? {}), [REMOTE_NOTICE_SECTION]: buildRemoteNotice(remote) };
|
|
212
|
+
log("[remote] 首次向 pi 交付 QQ 消息,已注入远程上下文说明(系统提示 section)");
|
|
213
|
+
}
|
|
214
|
+
// 又轮到 QQ 了:刚才的本机接管待定作废
|
|
215
|
+
state.localHandoffPending = false;
|
|
216
|
+
state.lastRunOrigin = "qq";
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 本机 TUI/CLI 直接开跑:上一轮是 QQ 的话,更正一次「远程前提作废」
|
|
221
|
+
const handoff = state.localHandoffPending;
|
|
222
|
+
state.localHandoffPending = false;
|
|
223
|
+
state.lastRunOrigin = "local";
|
|
224
|
+
if (!handoff) return;
|
|
225
|
+
options.sections = { ...(options.sections ?? {}), [LOCAL_HANDOFF_SECTION]: buildLocalHandoffNotice() };
|
|
226
|
+
log("[remote] 本机接管本轮对话,已注入「远程前提作废」更正");
|
|
227
|
+
});
|
|
228
|
+
|
|
156
229
|
// ---- 事件:run 开始,记住 ctx(取消指令要调用 ctx.abort())----
|
|
157
230
|
pi.on("agent_start", (_event, ctx) => {
|
|
158
231
|
state.ctx = ctx;
|
|
@@ -161,6 +234,7 @@ export default function (
|
|
|
161
234
|
clearCancelTimer(state);
|
|
162
235
|
state.progress.start();
|
|
163
236
|
state.progressPaused = false;
|
|
237
|
+
state.progressHeldForApproval = false;
|
|
164
238
|
startProgressTimer(pi, state);
|
|
165
239
|
});
|
|
166
240
|
|
|
@@ -317,6 +391,11 @@ export default function (
|
|
|
317
391
|
|
|
318
392
|
// 新会话:清空 remember 记忆与自动同意开关(自动同意只在当前会话有效)
|
|
319
393
|
state.approver.reset();
|
|
394
|
+
// 远程上下文提醒同样只在本会话提醒一次
|
|
395
|
+
state.remoteNotified = false;
|
|
396
|
+
// 「QQ ↔ 本机」的来源切换状态也随会话重置
|
|
397
|
+
state.lastRunOrigin = null;
|
|
398
|
+
state.localHandoffPending = false;
|
|
320
399
|
// 手动暂停的自动汇报同样只在本会话有效
|
|
321
400
|
state.progressMuted = false;
|
|
322
401
|
state.progressPaused = false;
|
|
@@ -490,6 +569,12 @@ export default function (
|
|
|
490
569
|
enterStandby();
|
|
491
570
|
});
|
|
492
571
|
|
|
572
|
+
// ---- 事件:上下文压缩可能把「远程用户」提醒压掉:下一条消息重新注入 ----
|
|
573
|
+
pi.on("session_compact", (event) => {
|
|
574
|
+
state.remoteNotified = false;
|
|
575
|
+
log("[remote] 上下文已压缩(reason=", String((event as any)?.reason ?? "?"), "),下一条消息重新注入提醒");
|
|
576
|
+
});
|
|
577
|
+
|
|
493
578
|
// ---- 事件:session_shutdown 清理 ----
|
|
494
579
|
pi.on("session_shutdown", async (event) => {
|
|
495
580
|
const reason = String((event as any)?.reason ?? "quit");
|
|
@@ -719,6 +804,7 @@ function startJob(pi: ExtensionAPI, state: State, job: QueuedJob) {
|
|
|
719
804
|
"text=",
|
|
720
805
|
job.text.slice(0, 60),
|
|
721
806
|
);
|
|
807
|
+
// 每会话第一条真正跑起来的 QQ 消息,由 before_agent_start 注入远程上下文说明。
|
|
722
808
|
try {
|
|
723
809
|
void Promise.resolve(
|
|
724
810
|
pi.sendUserMessage(job.text, { expandPromptTemplates: true, deliverAs: "followUp" }),
|
|
@@ -1087,7 +1173,7 @@ export async function handleMessage(
|
|
|
1087
1173
|
[
|
|
1088
1174
|
"pi-qqbot 指令:",
|
|
1089
1175
|
"/progress — 查看进度;/progress off 暂停自动汇报、/progress on 恢复(本会话有效)",
|
|
1090
|
-
"/status — 查看状态(当前 qqbot / 忙闲 / 队列 / 自动同意 / 待审批 / 自动汇报)",
|
|
1176
|
+
"/status — 查看状态(当前 qqbot / 忙闲 / 来源 / 队列 / 自动同意 / 待审批 / 自动汇报)",
|
|
1091
1177
|
"/queue — 查看队列;/queue clear 清空;/queue drop <编号> 删一条",
|
|
1092
1178
|
"/reset — 清空回复缓冲、审批记忆与自动同意(不换会话;换会话发 /new)",
|
|
1093
1179
|
"/cancel(或「取消」「停止」「停」)— 中断正在运行的任务并清空队列",
|
|
@@ -1106,6 +1192,7 @@ export async function handleMessage(
|
|
|
1106
1192
|
const lines = [
|
|
1107
1193
|
`bot: ${state.account ? `${state.account.name} (…${state.account.appId.slice(-4)})` : "未接入"} · cwd: ${process.cwd()}`,
|
|
1108
1194
|
`状态: ${state.busy ? "处理中(发送 /cancel 可中断)" : state.queue.size > 0 ? "空闲,但有排队任务待派发" : "空闲"}`,
|
|
1195
|
+
`来源: ${describeRunOrigin(state)}`,
|
|
1109
1196
|
`队列: ${state.queue.size} 条待执行${state.queue.size > 0 ? "(发 /queue 查看)" : ""}`,
|
|
1110
1197
|
`自动同意: ${state.approver.autoAllowed ? "已开启(本会话有效)" : "未开启"}`,
|
|
1111
1198
|
`待审批: ${state.approver.pendingCount}`,
|
|
@@ -1268,9 +1355,22 @@ function stopProgressTimer(state: State) {
|
|
|
1268
1355
|
/** 推一条进度摘要;失败就暂停自动推送(用户问一次即恢复);手动暂停(/progress off)管到底 */
|
|
1269
1356
|
async function pushProgress(state: State, reason: "tick" | "ask"): Promise<boolean> {
|
|
1270
1357
|
if (reason === "tick" && (state.progressPaused || state.progressMuted)) return false;
|
|
1358
|
+
// 有审批在等用户回复:暂停自动推送,否则每 15s 一条进度会把审批提示顶出屏幕
|
|
1359
|
+
// (手动问进度不受影响:用户自己要看的)
|
|
1360
|
+
if (reason === "tick" && state.approver.pendingCount > 0) {
|
|
1361
|
+
if (!state.progressHeldForApproval) {
|
|
1362
|
+
state.progressHeldForApproval = true;
|
|
1363
|
+
log("[progress] 有", state.approver.pendingCount, "个待审批,暂缓自动进度推送");
|
|
1364
|
+
}
|
|
1365
|
+
return false;
|
|
1366
|
+
}
|
|
1271
1367
|
const api = state.api;
|
|
1272
1368
|
const openid = state.currentOpenid ?? state.lastOpenid;
|
|
1273
1369
|
if (!api || !openid || !state.busy || !state.progress.running) return false;
|
|
1370
|
+
if (state.progressHeldForApproval) {
|
|
1371
|
+
state.progressHeldForApproval = false;
|
|
1372
|
+
log("[progress] 审批已处理,恢复自动进度推送");
|
|
1373
|
+
}
|
|
1274
1374
|
const text = state.progress.summary();
|
|
1275
1375
|
if (!text) return false;
|
|
1276
1376
|
const ok = await replyTo(api, state, openid, text);
|
|
@@ -1281,6 +1381,13 @@ async function pushProgress(state: State, reason: "tick" | "ask"): Promise<boole
|
|
|
1281
1381
|
return ok;
|
|
1282
1382
|
}
|
|
1283
1383
|
|
|
1384
|
+
/** /status 用:最近一轮对话是谁发起的 */
|
|
1385
|
+
function describeRunOrigin(state: State): string {
|
|
1386
|
+
if (state.lastRunOrigin === "qq") return "QQ(远程)";
|
|
1387
|
+
if (state.lastRunOrigin === "local") return "本机(TUI)";
|
|
1388
|
+
return "尚未开始";
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1284
1391
|
/** 自动汇报当前状态的人话描述(/status 用) */
|
|
1285
1392
|
function describeProgressMode(state: State): string {
|
|
1286
1393
|
const cfg = state.cfg?.progress;
|
|
@@ -1288,7 +1395,8 @@ function describeProgressMode(state: State): string {
|
|
|
1288
1395
|
if (cfg?.enabled === false) return "未启用(配置 progress.enabled=false)";
|
|
1289
1396
|
if (state.progressPaused) return "推送失败已静默,发 /progress 恢复";
|
|
1290
1397
|
const ms = Number.isFinite(cfg?.intervalMs) && (cfg?.intervalMs ?? 0) >= 50 ? cfg!.intervalMs : 15000;
|
|
1291
|
-
|
|
1398
|
+
const base = `开启(每 ${Math.round(ms / 1000)}s)`;
|
|
1399
|
+
return state.approver.pendingCount > 0 ? `${base},等审批期间暂缓` : base;
|
|
1292
1400
|
}
|
|
1293
1401
|
|
|
1294
1402
|
/** /progress 的子命令:暂停 / 恢复自动汇报 */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zxh126/pi-qqbot",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
]
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
|
-
"test": "node test/queue.test.mjs && node test/approver.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs && node test/progress.test.mjs && node test/redact.test.mjs && node test/config.test.mjs && node test/lock.test.mjs && node test/accounts.test.mjs && node test/auth.test.mjs && node test/attachment.test.mjs && node test/upload.test.mjs"
|
|
30
|
+
"test": "node test/queue.test.mjs && node test/approver.test.mjs && node test/logger.test.mjs && node test/usage-bridge.test.mjs && node test/progress.test.mjs && node test/redact.test.mjs && node test/config.test.mjs && node test/remote-notice.test.mjs && node test/lock.test.mjs && node test/accounts.test.mjs && node test/auth.test.mjs && node test/attachment.test.mjs && node test/upload.test.mjs"
|
|
31
31
|
},
|
|
32
32
|
"files": [
|
|
33
33
|
"*.ts",
|
package/remote.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { QQBotConfig } from "./config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 「以下对话来自远程用户」提醒。
|
|
5
|
+
*
|
|
6
|
+
* pi 在锁屏/无人值守的机器上替 QQ 侧的远程用户干活:对方看不见屏幕,也没法点弹窗。
|
|
7
|
+
* 所以在每个会话第一次真正把 QQ 消息交给 pi 跑起来时,把一段说明写进系统提示的
|
|
8
|
+
* `qq-remote-context` section(排在用户消息之前、QQ 侧不可见),让它从一开始就按
|
|
9
|
+
* 「远程 + 可能锁屏」的前提工作(默认离屏渲染,而不是截桌面)。
|
|
10
|
+
*
|
|
11
|
+
* 注入时机由 index.ts 的 before_agent_start 控制:只有本扩展派发的 QQ 消息真的开跑
|
|
12
|
+
* 才注入;仅载入扩展、或本机 TUI 直接对话都不会触发。上下文压缩后由 session_compact 复位。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface RemoteConfig {
|
|
16
|
+
/** 每会话第一条消息注入一次提醒 */
|
|
17
|
+
notice: boolean;
|
|
18
|
+
/** 每次来自 QQ 的 run 在系统提示里追加几条规则 */
|
|
19
|
+
guidelines: boolean;
|
|
20
|
+
/** 追加在默认提醒末尾的自定义补充(留空 = 只用默认文案) */
|
|
21
|
+
extra: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_REMOTE: RemoteConfig = {
|
|
25
|
+
notice: true,
|
|
26
|
+
guidelines: true,
|
|
27
|
+
extra: "",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** 说明放在系统提示的哪个 section 里(排查会话文件时靠这个 tag 认出来) */
|
|
31
|
+
export const REMOTE_NOTICE_SECTION = "qq-remote-context";
|
|
32
|
+
|
|
33
|
+
/** 「远程前提作废」更正用的 section tag */
|
|
34
|
+
export const LOCAL_HANDOFF_SECTION = "qq-local-handoff";
|
|
35
|
+
|
|
36
|
+
/** 配置里的 `remote` 可能缺字段(老配置、测试里手搓的 cfg),这里补齐默认值 */
|
|
37
|
+
export function resolveRemoteConfig(cfg: Partial<Pick<QQBotConfig, "remote">> | null | undefined): RemoteConfig {
|
|
38
|
+
const raw = cfg?.remote ?? {};
|
|
39
|
+
return {
|
|
40
|
+
notice: raw.notice ?? DEFAULT_REMOTE.notice,
|
|
41
|
+
guidelines: raw.guidelines ?? DEFAULT_REMOTE.guidelines,
|
|
42
|
+
extra: String(raw.extra ?? DEFAULT_REMOTE.extra).trim(),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 首条消息前的说明:说清来源、锁屏风险、离屏渲染要求 */
|
|
47
|
+
export function buildRemoteNotice(remote: RemoteConfig): string {
|
|
48
|
+
const lines = [
|
|
49
|
+
"[pi-qqbot 远程上下文 · 说明,不必回复本条]",
|
|
50
|
+
"",
|
|
51
|
+
"接下来的对话来自「远程 QQ 私聊用户」,不是本机终端用户。前提:",
|
|
52
|
+
"",
|
|
53
|
+
"1. 对方看不到本机屏幕、也收不到弹窗;回复应简明扼要。",
|
|
54
|
+
"2. 本机可能锁屏或无人值守。默认不要用桌面截屏、屏幕 OCR、UI 自动化、键鼠模拟、带界面的浏览器" +
|
|
55
|
+
"(例如 mcp__screenshot、mcp__winui 这类工具):锁屏下它们只会拍到锁屏画面或直接失败。",
|
|
56
|
+
"3. 需要图/界面效果时,按项目类型自行选择离屏渲染方案(headless 浏览器渲 HTML、SVG、matplotlib/Pillow 等)," +
|
|
57
|
+
"生成 PNG 后用 qq_send_file 发到 QQ。不要为了“确认”先截桌面。",
|
|
58
|
+
"4. 只有确实需要判断是否锁屏时,才允许截一次桌面看有没有内容;其余一律以离屏渲染为准。",
|
|
59
|
+
"5. 需要人工介入(登录、验证码、实体操作)时直接说明并给替代方案;" +
|
|
60
|
+
"危险工具会先发到 QQ 等审批,对方可能不在电脑旁。",
|
|
61
|
+
];
|
|
62
|
+
if (remote.extra) lines.push("", remote.extra);
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 本机接管后的更正:只在「上一轮来自 QQ」的第一次本机对话注入(只生效一轮) */
|
|
67
|
+
export function buildLocalHandoffNotice(): string {
|
|
68
|
+
return [
|
|
69
|
+
"现在是本机用户在终端直接对话(同一会话内接管),之前「远程 QQ 用户」的前提作废:",
|
|
70
|
+
"可以正常截屏 / 看屏幕,回复详略按本机需求来,不需要用 qq_send_file 交付。",
|
|
71
|
+
].join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 系统提示里追加的规则(比首条提醒更短,每次 QQ run 都带上) */
|
|
75
|
+
export function buildRemoteGuidelines(): string[] {
|
|
76
|
+
return [
|
|
77
|
+
"对话来自远程 QQ 用户:对方看不到本机屏幕,回复应简明扼要。",
|
|
78
|
+
"本机可能锁屏:默认禁用桌面截屏/屏幕 OCR/UI 自动化/键鼠模拟/带界面浏览器;要图就离屏渲染,再用 qq_send_file 发送。",
|
|
79
|
+
"需要本机人工操作时直接说明,不要等待或反复重试。",
|
|
80
|
+
];
|
|
81
|
+
}
|