@hwj123weijian/pi-feishu 0.1.0 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-feishu
2
2
 
3
- 一个独立、极简的 [Pi](https://github.com/earendil-works/pi-mono) 飞书扩展。它通过飞书官方 Node SDK 的 WebSocket 长连接,把飞书私聊文本转发到当前 Pi 会话,并把最终回复发回原私聊。
3
+ 一个独立的 [Pi](https://github.com/earendil-works/pi-mono) 飞书扩展。它通过飞书官方 Node SDK 的 WebSocket 长连接,把飞书私聊文本转发到当前 Pi 会话,并以持续更新的交互卡片展示 Pi 的回复和执行状态。
4
4
 
5
5
  ## 设计边界
6
6
 
@@ -8,10 +8,18 @@
8
8
 
9
9
  - 仅处理飞书私聊(`p2p`)文本消息
10
10
  - 首次启动生成一次性绑定码,只允许一个 Owner
11
+ - 收到消息后立刻在原消息上添加“思考中”表情作为已读回执,回复送达后自动移除
12
+ - 以 `/` 开头的消息走远程命令快速通道,不排队、不进入 LLM 上下文
11
13
  - 所有消息进入当前 Pi 会话,不创建额外 Agent 或会话
14
+ - 消息需要排队时发送“排队中(第 N 位)”提示,轮到处理时自动撤回;队列变化时提示自动改写为新位数
15
+ - Owner 在排队中的消息上点 ❌(`CrossMark`)表情即可取消该消息
16
+ - Assistant 文本通过 Pi 的 `message_update` 增量渲染到同一张飞书卡片
17
+ - 工具执行时仅显示安全的状态摘要,不展示参数、命令、文件内容或工具输出
18
+ - SDK 以 500ms / 120 字符节流卡片更新;完成、失败、中止都会收尾卡片
19
+ - 缺少更新权限或飞书卡片更新失败时,自动退化为原私聊的一次性文本回复
12
20
  - 串行处理消息,避免多条飞书消息同时驱动 Pi
13
21
  - 按飞书 `message_id` 去重
14
- - `/feishu stop`、`/feishu logout` 和 Pi 会话关闭时释放长连接
22
+ - 长连接由跨会话的全局控制器保管:本地 `/new`、远程 `/new` 等会话切换不会断开;`/feishu stop`、`/feishu logout` 或 Pi 进程退出时释放
15
23
  - 凭据优先从环境变量读取,也可保存到本地凭据文件
16
24
 
17
25
  当前不支持群聊、图片、文件、卡片、语音、多用户、多会话、进程级沙箱或远程命令权限管理。这里的安全边界是“私聊 + 单 Owner”,Pi 本身仍拥有当前本地进程的权限。
@@ -30,11 +38,13 @@
30
38
  2. 在“权限管理”中申请:
31
39
  - `im:message.p2p_msg:readonly`:接收私聊消息
32
40
  - `im:message:send_as_bot`:以机器人身份回复
41
+ - `im:message:update`:持续更新机器人发出的交互卡片
42
+ - 表情回复相关权限:在原消息上添加/移除“思考中”已读回执
33
43
  3. 在“事件与回调”中选择“使用长连接接收事件”。
34
44
  4. 添加事件 `im.message.receive_v1`。
35
45
  5. 创建并发布一个应用版本,使权限和事件订阅在企业内生效。
36
46
 
37
- 这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。
47
+ 这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。若未授予 `im:message:update`,扩展仍可工作,但会降级为最终文本一次性回复。若未授予表情回复权限,扩展同样可工作,只是原消息上不会出现已读表情。
38
48
 
39
49
  ## 安装
40
50
 
@@ -88,7 +98,7 @@ pi -e D:\ai_study\pi-feishu
88
98
  /bind 123456
89
99
  ```
90
100
 
91
- 绑定成功后,直接私聊机器人即可驱动当前 Pi 会话。
101
+ 绑定成功后,直接私聊机器人即可驱动当前 Pi 会话。原消息会立刻出现“思考中”表情(已读回执),处理过程中先显示“正在思考”,随后持续更新正文;Pi 调用工具时卡片会显示“正在执行工具”,最终状态变为“已完成”,同时已读表情自动移除。
92
102
 
93
103
  也支持手动参数:
94
104
 
@@ -117,6 +127,28 @@ pi -e D:\ai_study\pi-feishu
117
127
 
118
128
  环境变量优先于凭据文件。如果环境变量中的 App ID 与已保存的 App ID 不同,旧 Owner 绑定不会被继承。
119
129
 
130
+ ### 飞书远程命令
131
+
132
+ 在飞书私聊里直接发送以下命令,会走高优先级快速通道:不进入消息队列排队,也不会作为提问发给 Pi。
133
+
134
+ | 命令 | 作用 |
135
+ | --- | --- |
136
+ | `/help` | 显示远程命令帮助 |
137
+ | `/new` | 新建 Pi 会话(丢弃当前对话上下文) |
138
+ | `/stop` | 中止当前任务,并跳过队列中剩余消息 |
139
+ | `/status` | 查看连接、队列、模型与上下文占用 |
140
+ | `/compact` | 压缩当前会话上下文 |
141
+ | `/thinking` | 查看思考档位,或切换:`/thinking off|minimal|low|medium|high|xhigh|max` |
142
+ | `/model` | 查看当前模型,或模糊匹配切换:`/model <模型ID或名称>` |
143
+
144
+ 未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
145
+
146
+ ### 取消排队中的消息
147
+
148
+ 消息排队期间,Owner 直接在**自己的这条消息**上点 ❌(`CrossMark`)表情即可取消:排队提示与已读表情立即清理,消息不会发给 Pi,后面消息的排队提示自动前移。仅 Owner 的取消表情生效,已开工的消息不能取消(用 `/stop` 中止)。
149
+
150
+ 飞书的“消息撤回”事件未被官方 Node SDK 的 channel 抽象透出(长连接也不允许挂第二条连接分流事件),因此这里以 ❌ 表情作为取消入口,效果等价于撤回后移出队列。
151
+
120
152
  ## 常见问题
121
153
 
122
154
  ### `setup` 或 `start` 连接失败
@@ -131,6 +163,18 @@ pi -e D:\ai_study\pi-feishu
131
163
 
132
164
  确认已申请并发布 `im:message:send_as_bot` 权限。
133
165
 
166
+ ### 只能收到最终文本,没有流式卡片
167
+
168
+ 确认已申请并发布 `im:message:update`。卡片初始化或更新失败时,扩展会自动改为最终文本回复,避免用户没有任何反馈。
169
+
170
+ ### 没有已读表情
171
+
172
+ 确认已申请并发布表情回复相关权限。该权限缺失时扩展自动降级:消息照常收发,只是原消息上不会出现“思考中”表情。
173
+
174
+ ### 远程 `/new` 提示暂不可用
175
+
176
+ 远程会话切换依赖本地 Pi 提供的命令上下文。先在本地 Pi 执行任意 `/feishu` 子命令(例如 `/feishu status`),再从飞书发送 `/new`。切换完成后长连接自动延续,无需重新执行 `/feishu start`。
177
+
134
178
  ### Bot 提示“未授权”
135
179
 
136
180
  该 Bot 已绑定其他 Owner。若要重新绑定,先在本地 Pi 执行 `/feishu logout`,再重新 `setup`、`start` 和 `/bind`。
@@ -147,7 +191,7 @@ npm test
147
191
  npm run build
148
192
  ```
149
193
 
150
- 测试使用 Fake Gateway 和 Fake Agent,不需要真实飞书凭据,覆盖凭据处理、Owner 绑定、私聊过滤、串行队列、去重、完整消息链路、错误脱敏和清理行为。
194
+ 测试使用 Fake Gateway 和 Fake Agent,不需要真实飞书凭据,覆盖凭据处理、Owner 绑定、私聊过滤、串行队列、排队提示改位与撤回、❌ 取消、`/stop` 按条跳过、去重、已读表情回执、远程命令快速通道、思考档位与模型切换、Pi 增量文本、工具状态、流式卡片收尾、降级、错误脱敏和清理行为。
151
195
 
152
196
  ## 许可证
153
197
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwj123weijian/pi-feishu",
3
- "version": "0.1.0",
3
+ "version": "0.5.0",
4
4
  "description": "Minimal Feishu private-chat bridge for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/contracts.ts CHANGED
@@ -21,14 +21,94 @@ export interface FeishuIncomingMessage {
21
21
 
22
22
  export type FeishuMessageHandler = (message: FeishuIncomingMessage) => Promise<void> | void;
23
23
 
24
+ export interface FeishuReactionEvent {
25
+ messageId: string;
26
+ operatorOpenId: string;
27
+ emojiType: string;
28
+ action: "added" | "removed";
29
+ }
30
+
31
+ export type FeishuReactionHandler = (event: FeishuReactionEvent) => void;
32
+
33
+ export interface FeishuReplySnapshot {
34
+ text: string;
35
+ status: "正在思考" | "正在生成回复" | "正在执行工具" | "已完成" | "处理失败" | "已取消";
36
+ }
37
+
38
+ export interface FeishuReply {
39
+ update(snapshot: FeishuReplySnapshot): void;
40
+ complete(snapshot: FeishuReplySnapshot): Promise<void>;
41
+ fail(): Promise<void>;
42
+ cancel(): Promise<void>;
43
+ }
44
+
24
45
  export interface FeishuGateway {
25
46
  connect(handler: FeishuMessageHandler): Promise<void>;
26
47
  disconnect(): Promise<void>;
27
- sendText(chatId: string, text: string, replyTo?: string): Promise<void>;
48
+ /** Sends text and resolves with the sent Feishu message id (for later recall). */
49
+ sendText(chatId: string, text: string, replyTo?: string): Promise<string | undefined>;
50
+ beginReply(chatId: string, replyTo: string): Promise<FeishuReply>;
51
+ /** Edits an already-sent text message in place. */
52
+ editText(messageId: string, text: string): Promise<void>;
53
+ /** Adds an emoji reaction to a message and returns the Feishu reaction id. */
54
+ addReaction(messageId: string, emojiType: string): Promise<string>;
55
+ removeReaction(messageId: string, reactionId: string): Promise<void>;
56
+ recallMessage(messageId: string): Promise<void>;
57
+ /** Subscribes to emoji reactions on messages visible to the bot. */
58
+ onReaction(handler: FeishuReactionHandler): () => void;
59
+ }
60
+
61
+ export interface FeishuStatus {
62
+ configured: boolean;
63
+ running: boolean;
64
+ ownerOpenId?: string;
65
+ appId?: string;
66
+ source?: "environment" | "file";
67
+ pendingMessages: number;
68
+ }
69
+
70
+ export interface PiRuntimeSnapshot {
71
+ streaming: boolean;
72
+ model?: string;
73
+ thinkingLevel?: string;
74
+ contextPercent?: number;
75
+ }
76
+
77
+ export interface PiModelInfo {
78
+ id: string;
79
+ name: string;
80
+ provider: string;
81
+ }
82
+
83
+ /**
84
+ * Exposes the Pi runtime capabilities needed by remote slash commands.
85
+ * Implementations must be defensive: the captured context goes stale after
86
+ * a session switch, so methods may become no-ops until the next event.
87
+ */
88
+ export interface PiRuntime {
89
+ isIdle(): boolean;
90
+ abort(): void;
91
+ compact(): void;
92
+ /** Returns false when the runtime is unavailable or the switch was cancelled. */
93
+ newSession(): Promise<boolean>;
94
+ /** Returns false when the requested thinking level does not exist. */
95
+ setThinkingLevel(level: string): Promise<boolean>;
96
+ /** Models the user can currently switch to. */
97
+ listModels(): PiModelInfo[];
98
+ /** Switches by fuzzy id/name match; throws with a user-facing reason on failure. */
99
+ switchModel(query: string): Promise<PiModelInfo>;
100
+ snapshot(): PiRuntimeSnapshot;
101
+ }
102
+
103
+ export type AgentActivity = { kind: "thinking" } | { kind: "tool"; toolName: string };
104
+
105
+ export interface AgentProgressObserver {
106
+ onText?: (text: string) => void;
107
+ onActivity?: (activity: AgentActivity) => void;
28
108
  }
29
109
 
30
110
  export interface AgentBridge {
31
- run(text: string): Promise<string>;
111
+ run(text: string, observer?: AgentProgressObserver): Promise<string>;
32
112
  cancel(reason?: string): void;
33
113
  }
34
114
 
package/src/controller.ts CHANGED
@@ -7,17 +7,40 @@ import type {
7
7
  FeishuGateway,
8
8
  FeishuGatewayFactory,
9
9
  FeishuIncomingMessage,
10
+ FeishuReactionEvent,
11
+ FeishuReply,
12
+ FeishuStatus,
13
+ PiRuntime,
10
14
  } from "./contracts.js";
11
15
  import { CredentialError, errorMessage, resolveCredentialInput, resolveRuntimeCredentials } from "./credentials.js";
12
16
  import { MessageDeduplicator } from "./message-deduplicator.js";
13
17
  import { SerialMessageQueue } from "./message-queue.js";
14
18
  import { OwnerBinding } from "./owner-binding.js";
19
+ import { executeRemoteCommand, parseRemoteCommand } from "./remote-commands.js";
20
+
21
+ // 与 easycodeclient 的飞书集成一致:THINKING 表情兼作"已读 + 处理中"回执。
22
+ const READ_REACTION_EMOJI = "THINKING";
23
+
24
+ interface PendingTask {
25
+ messageId: string;
26
+ chatId: string;
27
+ reactionId: string;
28
+ tipText: string;
29
+ tipMessageId: string | undefined;
30
+ started: boolean;
31
+ cancelled: boolean;
32
+ }
33
+
34
+ function queueTipText(position: number): string {
35
+ return `⏳ 已收到,排队中(第 ${position} 位),将在当前任务完成后处理。`;
36
+ }
15
37
 
16
38
  export interface FeishuControllerOptions {
17
39
  store: CredentialStore;
18
40
  gatewayFactory: FeishuGatewayFactory;
19
41
  validateCredentials: CredentialValidator;
20
42
  agent: AgentBridge;
43
+ runtime?: PiRuntime;
21
44
  generateBindingCode?: () => string;
22
45
  }
23
46
 
@@ -26,32 +49,28 @@ export interface FeishuStartResult {
26
49
  bindingCode?: string;
27
50
  }
28
51
 
29
- export interface FeishuStatus {
30
- configured: boolean;
31
- running: boolean;
32
- ownerOpenId?: string;
33
- appId?: string;
34
- source?: "environment" | "file";
35
- pendingMessages: number;
36
- }
37
-
38
52
  export class FeishuController {
39
53
  private readonly store: CredentialStore;
40
54
  private readonly gatewayFactory: FeishuGatewayFactory;
41
55
  private readonly validateCredentials: CredentialValidator;
42
56
  private readonly agent: AgentBridge;
57
+ private readonly runtime: PiRuntime | undefined;
43
58
  private readonly generateBindingCode: (() => string) | undefined;
44
59
  private readonly queue = new SerialMessageQueue();
45
60
  private readonly deduplicator = new MessageDeduplicator();
46
61
  private gateway: FeishuGateway | undefined;
47
62
  private credentials: FeishuCredentials | undefined;
48
63
  private binding: OwnerBinding | undefined;
64
+ private environment: Environment = {};
65
+ private readonly pendingTasks = new Map<string, PendingTask>();
66
+ private readonly activeReplies = new Set<FeishuReply>();
49
67
 
50
68
  constructor(options: FeishuControllerOptions) {
51
69
  this.store = options.store;
52
70
  this.gatewayFactory = options.gatewayFactory;
53
71
  this.validateCredentials = options.validateCredentials;
54
72
  this.agent = options.agent;
73
+ this.runtime = options.runtime;
55
74
  this.generateBindingCode = options.generateBindingCode;
56
75
  }
57
76
 
@@ -89,6 +108,7 @@ export class FeishuController {
89
108
  this.credentials = credentials;
90
109
  this.binding = binding;
91
110
  this.gateway = gateway;
111
+ this.environment = environment;
92
112
  this.deduplicator.clear();
93
113
 
94
114
  try {
@@ -99,6 +119,7 @@ export class FeishuController {
99
119
  await gateway.disconnect().catch(() => undefined);
100
120
  throw new CredentialError(`启动飞书长连接失败:${errorMessage(error, credentials)}`);
101
121
  }
122
+ gateway.onReaction((event) => this.handleReaction(event));
102
123
 
103
124
  const bindingCode = binding.getOrCreateCode();
104
125
  return bindingCode ? { alreadyRunning: false, bindingCode } : { alreadyRunning: false };
@@ -107,6 +128,10 @@ export class FeishuController {
107
128
  async stop(): Promise<boolean> {
108
129
  const gateway = this.gateway;
109
130
  if (!gateway) return false;
131
+ for (const entry of this.pendingTasks.values()) entry.cancelled = true;
132
+ this.pendingTasks.clear();
133
+ await Promise.all([...this.activeReplies].map((reply) => reply.cancel().catch(() => undefined)));
134
+ this.activeReplies.clear();
110
135
  this.gateway = undefined;
111
136
  this.binding = undefined;
112
137
  this.agent.cancel("飞书连接已停止。");
@@ -145,36 +170,139 @@ export class FeishuController {
145
170
  if (!gateway || !binding || message.chatType !== "p2p" || message.contentType !== "text") return;
146
171
  if (!message.messageId || !this.deduplicator.accept(message.messageId)) return;
147
172
 
173
+ const reactionId = await this.ackRead(gateway, message.messageId);
148
174
  const authorization = binding.authorize(message.senderOpenId, message.text);
149
175
  switch (authorization.kind) {
150
176
  case "binding-required":
151
- await this.safeSend(gateway, message, "Bot 尚未绑定,请在本地 Pi 查看一次性绑定码。");
177
+ await this.acknowledge(gateway, message, reactionId, "Bot 尚未绑定,请在本地 Pi 查看一次性绑定码。");
152
178
  return;
153
179
  case "invalid-binding-code":
154
- await this.safeSend(gateway, message, "绑定码无效,请检查本地 Pi 显示的一次性绑定码。");
180
+ await this.acknowledge(gateway, message, reactionId, "绑定码无效,请检查本地 Pi 显示的一次性绑定码。");
155
181
  return;
156
182
  case "unauthorized":
157
- await this.safeSend(gateway, message, "未授权:此 Bot 仅响应已绑定的 Owner。");
183
+ await this.acknowledge(gateway, message, reactionId, "未授权:此 Bot 仅响应已绑定的 Owner。");
158
184
  return;
159
185
  case "bound":
160
186
  await this.persistOwner(authorization.ownerOpenId);
161
- await this.safeSend(gateway, message, "绑定成功,现在可以直接发送问题。");
187
+ await this.acknowledge(gateway, message, reactionId, "绑定成功,现在可以直接发送问题。");
162
188
  return;
163
189
  case "authorized":
164
- void this.queue.enqueue(async () => {
165
- if (this.gateway !== gateway) return;
166
- try {
167
- const response = await this.agent.run(authorization.text);
168
- if (this.gateway === gateway) {
169
- await gateway.sendText(message.chatId, response, message.messageId);
170
- }
171
- } catch {
172
- if (this.gateway === gateway) {
173
- await this.safeSend(gateway, message, "处理消息失败,请稍后再试。");
174
- }
190
+ if (parseRemoteCommand(authorization.text)) {
191
+ // 斜杠命令快速通道:不进入消息队列,也不进入 LLM 上下文。
192
+ await this.runRemoteCommand(gateway, message, reactionId, authorization.text);
193
+ return;
194
+ }
195
+ void this.enqueueAgentTask(gateway, message, authorization.text, reactionId);
196
+ }
197
+ }
198
+
199
+ private enqueueAgentTask(
200
+ gateway: FeishuGateway,
201
+ message: FeishuIncomingMessage,
202
+ text: string,
203
+ reactionId: string,
204
+ ): void {
205
+ const entry: PendingTask = {
206
+ messageId: message.messageId,
207
+ chatId: message.chatId,
208
+ reactionId,
209
+ tipText: "",
210
+ tipMessageId: undefined,
211
+ started: false,
212
+ cancelled: false,
213
+ };
214
+ // Map 保持插入顺序:首位是正在处理的任务,其余按排队先后排列。
215
+ const position = this.pendingTasks.size + 1;
216
+ this.pendingTasks.set(message.messageId, entry);
217
+
218
+ if (position > 1) {
219
+ entry.tipText = queueTipText(position);
220
+ void gateway
221
+ .sendText(message.chatId, entry.tipText, message.messageId)
222
+ .then((tipId) => {
223
+ if (!tipId) return;
224
+ // 提示送达时任务已开工或已取消:提示已无意义,直接撤回。
225
+ if (entry.cancelled || entry.started) {
226
+ void gateway.recallMessage(tipId).catch(() => undefined);
227
+ return;
175
228
  }
176
- });
229
+ entry.tipMessageId = tipId;
230
+ this.refreshQueueTips(gateway);
231
+ })
232
+ .catch(() => undefined);
233
+ }
234
+
235
+ void this.queue.enqueue(async () => {
236
+ entry.started = true;
237
+ try {
238
+ this.recallTip(gateway, entry);
239
+ if (this.gateway !== gateway) return;
240
+ // 被取消的任务静默跳过:回执与提示已由取消方清理。
241
+ if (entry.cancelled) return;
242
+ await this.processWithAgent(gateway, message, text);
243
+ } finally {
244
+ this.pendingTasks.delete(message.messageId);
245
+ this.refreshQueueTips(gateway);
246
+ await this.clearRead(gateway, message.messageId, reactionId);
247
+ }
248
+ });
249
+ }
250
+
251
+ private recallTip(gateway: FeishuGateway, entry: PendingTask): void {
252
+ const tipId = entry.tipMessageId;
253
+ if (!tipId) return;
254
+ entry.tipMessageId = undefined;
255
+ void gateway.recallMessage(tipId).catch(() => undefined);
256
+ }
257
+
258
+ /** 排队提示动态改位:前方的任务完成或被移除后,剩余提示更新为新位数。 */
259
+ private refreshQueueTips(gateway: FeishuGateway): void {
260
+ let position = 0;
261
+ for (const entry of this.pendingTasks.values()) {
262
+ position += 1;
263
+ // 首位要么正在处理、要么即将开工(提示随即撤回),无需改写。
264
+ if (position === 1 || entry.started || !entry.tipMessageId) continue;
265
+ const text = queueTipText(position);
266
+ if (text === entry.tipText) continue;
267
+ entry.tipText = text;
268
+ void gateway.editText(entry.tipMessageId, text).catch(() => undefined);
269
+ }
270
+ }
271
+
272
+ /** 中止当前任务之后的所有排队消息,返回跳过数量。 */
273
+ private stopQueuedMessages(gateway: FeishuGateway): number {
274
+ let skipped = 0;
275
+ for (const entry of [...this.pendingTasks.values()]) {
276
+ if (entry.started) continue;
277
+ entry.cancelled = true;
278
+ skipped += 1;
279
+ this.recallTip(gateway, entry);
280
+ void this.clearRead(gateway, entry.messageId, entry.reactionId);
281
+ entry.reactionId = "";
282
+ // 立即移出登记表:后续提示改位不再把它算作在队。
283
+ this.pendingTasks.delete(entry.messageId);
177
284
  }
285
+ return skipped;
286
+ }
287
+
288
+ /** Owner 在排队中的消息上点 ❌(或 NO)表情:取消该消息并清理提示与回执。 */
289
+ handleReaction(event: FeishuReactionEvent): void {
290
+ const gateway = this.gateway;
291
+ const binding = this.binding;
292
+ if (!gateway || !binding) return;
293
+ if (event.action !== "added") return;
294
+ const normalized = event.emojiType.toLowerCase();
295
+ if (normalized !== "crossmark" && normalized !== "no") return;
296
+ if (event.operatorOpenId !== binding.ownerOpenId) return;
297
+ const entry = this.pendingTasks.get(event.messageId);
298
+ if (!entry || entry.started || entry.cancelled) return;
299
+
300
+ entry.cancelled = true;
301
+ this.recallTip(gateway, entry);
302
+ void this.clearRead(gateway, entry.messageId, entry.reactionId);
303
+ entry.reactionId = "";
304
+ this.pendingTasks.delete(entry.messageId);
305
+ this.refreshQueueTips(gateway);
178
306
  }
179
307
 
180
308
  async waitForIdle(): Promise<void> {
@@ -193,6 +321,82 @@ export class FeishuController {
193
321
  this.credentials = next;
194
322
  }
195
323
 
324
+ private async processWithAgent(gateway: FeishuGateway, message: FeishuIncomingMessage, text: string): Promise<void> {
325
+ if (this.gateway !== gateway) return;
326
+ const reply = await gateway.beginReply(message.chatId, message.messageId).catch(() => undefined);
327
+ if (reply) this.activeReplies.add(reply);
328
+ let latestText = "";
329
+ try {
330
+ const response = await this.agent.run(text, {
331
+ onText: (latest) => {
332
+ latestText = latest;
333
+ reply?.update({ text: latest, status: "正在生成回复" });
334
+ },
335
+ onActivity: (activity) => {
336
+ reply?.update({
337
+ text: latestText,
338
+ status: activity.kind === "tool" ? "正在执行工具" : "正在思考",
339
+ });
340
+ },
341
+ });
342
+ if (this.gateway === gateway) {
343
+ if (reply) {
344
+ await reply.complete({ text: response, status: "已完成" });
345
+ } else {
346
+ await gateway.sendText(message.chatId, response, message.messageId);
347
+ }
348
+ }
349
+ } catch {
350
+ if (this.gateway === gateway) {
351
+ if (reply) {
352
+ await reply.fail();
353
+ } else {
354
+ await this.safeSend(gateway, message, "处理消息失败,请稍后再试。");
355
+ }
356
+ }
357
+ } finally {
358
+ if (reply) this.activeReplies.delete(reply);
359
+ }
360
+ }
361
+
362
+ private async runRemoteCommand(
363
+ gateway: FeishuGateway,
364
+ message: FeishuIncomingMessage,
365
+ reactionId: string,
366
+ text: string,
367
+ ): Promise<void> {
368
+ const replyText = await executeRemoteCommand(text, {
369
+ runtime: this.runtime,
370
+ status: () => this.status(this.environment),
371
+ stopQueue: () => this.stopQueuedMessages(gateway),
372
+ }).catch((error: unknown) => `执行命令出错:${this.sanitizeError(error)}`);
373
+ await this.acknowledge(gateway, message, reactionId, replyText);
374
+ }
375
+
376
+ private async ackRead(gateway: FeishuGateway, messageId: string): Promise<string> {
377
+ try {
378
+ return await gateway.addReaction(messageId, READ_REACTION_EMOJI);
379
+ } catch {
380
+ // 表情权限缺失时静默降级:不影响正常的收发消息流程。
381
+ return "";
382
+ }
383
+ }
384
+
385
+ private async clearRead(gateway: FeishuGateway, messageId: string, reactionId: string): Promise<void> {
386
+ if (!reactionId) return;
387
+ await gateway.removeReaction(messageId, reactionId).catch(() => undefined);
388
+ }
389
+
390
+ private async acknowledge(
391
+ gateway: FeishuGateway,
392
+ message: FeishuIncomingMessage,
393
+ reactionId: string,
394
+ text: string,
395
+ ): Promise<void> {
396
+ await this.safeSend(gateway, message, text);
397
+ await this.clearRead(gateway, message.messageId, reactionId);
398
+ }
399
+
196
400
  private async safeSend(gateway: FeishuGateway, message: FeishuIncomingMessage, text: string): Promise<void> {
197
401
  try {
198
402
  await gateway.sendText(message.chatId, text, message.messageId);
package/src/extension.ts CHANGED
@@ -1,10 +1,70 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { FeishuController, type FeishuStatus } from "./controller.js";
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentBridge, FeishuStatus, PiModelInfo, PiRuntime, PiRuntimeSnapshot } from "./contracts.js";
3
+ import { FeishuController } from "./controller.js";
3
4
  import { CredentialError, FileCredentialStore } from "./credentials.js";
4
5
  import { SdkFeishuGateway, validateSdkCredentials } from "./gateway.js";
5
6
  import { PiAgentBridge } from "./pi-agent-bridge.js";
7
+ import { THINKING_LEVELS } from "./remote-commands.js";
6
8
 
7
9
  type FeishuCommandName = "help" | "setup" | "start" | "stop" | "status" | "logout";
10
+ type PiModel = NonNullable<ExtensionContext["model"]>;
11
+ type PiThinkingLevel = NonNullable<ExtensionContext["thinkingLevel"]>;
12
+
13
+ interface SessionLink {
14
+ bridge: PiAgentBridge;
15
+ runtime: PiRuntime;
16
+ }
17
+
18
+ interface SharedFeishuState {
19
+ controller: FeishuController;
20
+ current: SessionLink | undefined;
21
+ }
22
+
23
+ const SHARED_STATE_KEY = "__piFeishuSharedState__";
24
+
25
+ /**
26
+ * Pi 会在每次会话切换(含远程 /new)后重新执行扩展工厂并重新求值模块,
27
+ * 因此只有挂在 globalThis 上的状态能跨会话存活:长连接、凭据和队列都在
28
+ * 这里,工厂重跑时只替换当前会话的 bridge/runtime。
29
+ */
30
+ function getSharedState(): SharedFeishuState {
31
+ const host = globalThis as typeof globalThis & { [SHARED_STATE_KEY]?: SharedFeishuState };
32
+ const existing = host[SHARED_STATE_KEY];
33
+ if (existing) return existing;
34
+
35
+ const state: SharedFeishuState = { current: undefined, controller: undefined as unknown as FeishuController };
36
+ const proxyAgent: AgentBridge = {
37
+ run: (text, observer) => {
38
+ const link = state.current;
39
+ if (!link) return Promise.reject(new Error("Pi 会话尚未就绪,请稍后再试。"));
40
+ return link.bridge.run(text, observer);
41
+ },
42
+ cancel: (reason) => state.current?.bridge.cancel(reason),
43
+ };
44
+ const proxyRuntime: PiRuntime = {
45
+ isIdle: () => state.current?.runtime.isIdle() ?? true,
46
+ abort: () => state.current?.runtime.abort(),
47
+ compact: () => state.current?.runtime.compact(),
48
+ newSession: () => state.current?.runtime.newSession() ?? Promise.resolve(false),
49
+ setThinkingLevel: (level) => state.current?.runtime.setThinkingLevel(level) ?? Promise.resolve(false),
50
+ listModels: () => state.current?.runtime.listModels() ?? [],
51
+ switchModel: async (query) => {
52
+ const link = state.current;
53
+ if (!link) throw new Error("Pi 会话尚未就绪,请稍后再试。");
54
+ return link.runtime.switchModel(query);
55
+ },
56
+ snapshot: () => state.current?.runtime.snapshot() ?? { streaming: false },
57
+ };
58
+ state.controller = new FeishuController({
59
+ store: new FileCredentialStore(),
60
+ gatewayFactory: (credentials) => new SdkFeishuGateway(credentials),
61
+ validateCredentials: validateSdkCredentials,
62
+ agent: proxyAgent,
63
+ runtime: proxyRuntime,
64
+ });
65
+ host[SHARED_STATE_KEY] = state;
66
+ return state;
67
+ }
8
68
 
9
69
  export interface ParsedFeishuCommand {
10
70
  name: FeishuCommandName;
@@ -67,37 +127,151 @@ export function renderFeishuStatus(status: FeishuStatus): string {
67
127
  }
68
128
 
69
129
  export default function feishuExtension(pi: ExtensionAPI): void {
70
- const agent = new PiAgentBridge((text) => pi.sendUserMessage(text));
71
- const controller = new FeishuController({
72
- store: new FileCredentialStore(),
73
- gatewayFactory: (credentials) => new SdkFeishuGateway(credentials),
74
- validateCredentials: validateSdkCredentials,
75
- agent,
76
- });
130
+ const state = getSharedState();
131
+ const bridge = new PiAgentBridge((text) => pi.sendUserMessage(text));
132
+ let latestContext: ExtensionContext | undefined;
133
+ let latestCommandContext: ExtensionCommandContext | undefined;
77
134
 
78
- pi.on("message_end", (event) => {
79
- agent.captureMessage(event.message);
135
+ const runtime: PiRuntime = {
136
+ isIdle: () => withRuntimeContext(() => latestContext?.isIdle()) ?? true,
137
+ abort: () => withRuntimeContext(() => latestContext?.abort()),
138
+ compact: () => withRuntimeContext(() => latestContext?.compact()),
139
+ newSession: async () => {
140
+ const context = latestCommandContext;
141
+ if (!context) return false;
142
+ try {
143
+ const result = await context.newSession();
144
+ return !result.cancelled;
145
+ } catch {
146
+ return false;
147
+ }
148
+ },
149
+ setThinkingLevel: async (level) => {
150
+ if (!isThinkingLevel(level)) return false;
151
+ try {
152
+ pi.setThinkingLevel(level);
153
+ return true;
154
+ } catch {
155
+ return false;
156
+ }
157
+ },
158
+ listModels: () => {
159
+ return availableModels(latestContext).map(toModelInfo);
160
+ },
161
+ switchModel: async (query) => {
162
+ const model = resolveModel(query, latestContext);
163
+ try {
164
+ const applied = await pi.setModel(model);
165
+ if (!applied) throw new Error(`模型 ${model.name} 缺少可用的 API Key。`);
166
+ } catch (error) {
167
+ throw error instanceof Error ? error : new Error(String(error));
168
+ }
169
+ return toModelInfo(model);
170
+ },
171
+ snapshot: () => {
172
+ const model = latestContext?.model;
173
+ const usage = withRuntimeContext(() => latestContext?.getContextUsage());
174
+ const snapshot: PiRuntimeSnapshot = {
175
+ streaming: !(withRuntimeContext(() => latestContext?.isIdle()) ?? true),
176
+ };
177
+ if (model) snapshot.model = model.name || model.id;
178
+ if (latestContext?.thinkingLevel) snapshot.thinkingLevel = latestContext.thinkingLevel;
179
+ if (typeof usage?.percent === "number") snapshot.contextPercent = usage.percent;
180
+ return snapshot;
181
+ },
182
+ };
183
+ state.current = { bridge, runtime };
184
+
185
+ // 每个事件都会带来新的 ExtensionContext;持续刷新,保证远程命令拿到的能力不失效。
186
+ const trackContext = (_event: unknown, context: ExtensionContext): void => {
187
+ latestContext = context;
188
+ };
189
+ pi.on("session_start", trackContext);
190
+ pi.on("agent_start", trackContext);
191
+ pi.on("message_end", (event, context) => {
192
+ trackContext(event, context);
193
+ bridge.captureMessage(event.message);
194
+ });
195
+ pi.on("message_update", (event, context) => {
196
+ trackContext(event, context);
197
+ bridge.captureStreamingMessage(event.message);
198
+ });
199
+ pi.on("tool_execution_start", (event, context) => {
200
+ trackContext(event, context);
201
+ bridge.captureToolStart(event.toolName);
80
202
  });
81
- pi.on("agent_settled", () => {
82
- agent.settle();
203
+ pi.on("tool_execution_end", (event, context) => {
204
+ trackContext(event, context);
205
+ bridge.captureToolEnd();
83
206
  });
84
- pi.on("session_shutdown", async () => {
85
- await controller.stop();
86
- agent.cancel("Pi 会话已关闭。");
207
+ pi.on("agent_settled", (event, context) => {
208
+ trackContext(event, context);
209
+ bridge.settle();
210
+ });
211
+ pi.on("session_shutdown", () => {
212
+ // 只解绑本会话的桥:长连接归全局 controller 保管,本地 /new 切换会话后自动延续。
213
+ bridge.cancel("Pi 会话已关闭。");
87
214
  });
88
215
 
89
216
  pi.registerCommand("feishu", {
90
217
  description: "配置和管理飞书私聊连接",
91
218
  handler: async (args, context) => {
219
+ latestCommandContext = context;
92
220
  try {
93
- await handleFeishuCommand(parseFeishuCommand(args), controller, context);
221
+ await handleFeishuCommand(parseFeishuCommand(args), state.controller, context);
94
222
  } catch (error) {
95
- context.ui.notify(`飞书操作失败:${controller.sanitizeError(error)}`, "error");
223
+ context.ui.notify(`飞书操作失败:${state.controller.sanitizeError(error)}`, "error");
96
224
  }
97
225
  },
98
226
  });
99
227
  }
100
228
 
229
+ function availableModels(context: ExtensionContext | undefined): PiModel[] {
230
+ const scoped = context?.scopedModels ?? [];
231
+ if (scoped.length > 0) return scoped.map((entry) => entry.model);
232
+ return withRuntimeContext(() => context?.modelRegistry.getAvailable()) ?? [];
233
+ }
234
+
235
+ function resolveModel(query: string, context: ExtensionContext | undefined): PiModel {
236
+ const candidates = availableModels(context);
237
+ const needle = query.trim().toLowerCase();
238
+ if (!needle) throw new Error("请提供模型 ID 或名称,例如 /model sonnet。");
239
+ if (candidates.length === 0) throw new Error("当前没有可用模型,请先在本地 Pi 配置模型或 API Key。");
240
+
241
+ const exact = candidates.filter((model) => model.id.toLowerCase() === needle);
242
+ if (exact.length === 1 && exact[0]) return exact[0];
243
+ if (exact.length > 1) throw new Error(`“${query}”匹配到多个模型,请输入更完整的模型 ID。`);
244
+
245
+ const fuzzy = candidates.filter(
246
+ (model) => model.id.toLowerCase().includes(needle) || model.name.toLowerCase().includes(needle),
247
+ );
248
+ if (fuzzy.length === 1 && fuzzy[0]) return fuzzy[0];
249
+ if (fuzzy.length === 0) throw new Error(`未找到匹配“${query}”的模型。`);
250
+ throw new Error(
251
+ `“${query}”匹配到多个模型:${fuzzy
252
+ .slice(0, 3)
253
+ .map((model) => model.id)
254
+ .join("、")}…请输入更完整的模型 ID。`,
255
+ );
256
+ }
257
+
258
+ function toModelInfo(model: PiModel): PiModelInfo {
259
+ return { id: model.id, name: model.name || model.id, provider: String(model.provider) };
260
+ }
261
+
262
+ function isThinkingLevel(value: string): value is PiThinkingLevel {
263
+ return (THINKING_LEVELS as readonly string[]).includes(value);
264
+ }
265
+
266
+ function withRuntimeContext<T>(action: () => T): T | undefined {
267
+ try {
268
+ return action();
269
+ } catch {
270
+ // 会话切换后捕获的 ctx 会失效,等下一个事件刷新即可。
271
+ return undefined;
272
+ }
273
+ }
274
+
101
275
  async function handleFeishuCommand(
102
276
  command: ParsedFeishuCommand,
103
277
  controller: FeishuController,
package/src/gateway.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { createLarkChannel, type LarkChannel, LoggerLevel, type NormalizedMessage } from "@larksuiteoapi/node-sdk";
2
- import type { FeishuCredentials, FeishuGateway, FeishuMessageHandler } from "./contracts.js";
2
+ import type {
3
+ FeishuCredentials,
4
+ FeishuGateway,
5
+ FeishuMessageHandler,
6
+ FeishuReactionHandler,
7
+ FeishuReply,
8
+ FeishuReplySnapshot,
9
+ } from "./contracts.js";
3
10
 
4
11
  export interface NormalizedChannelMessage {
5
12
  messageId: string;
@@ -12,9 +19,21 @@ export interface NormalizedChannelMessage {
12
19
 
13
20
  export interface ChannelLike {
14
21
  onMessage(handler: (message: NormalizedChannelMessage) => Promise<void> | void): () => void;
22
+ onReaction(handler: FeishuReactionHandler): () => void;
15
23
  connect(): Promise<void>;
16
24
  disconnect(): Promise<void>;
17
- sendText(to: string, text: string, replyTo?: string): Promise<void>;
25
+ /** Sends text and resolves with the sent Feishu message id (for later recall). */
26
+ sendText(to: string, text: string, replyTo?: string): Promise<string | undefined>;
27
+ startCardStream(to: string, card: object, replyTo?: string): Promise<ChannelCardStream>;
28
+ editText(messageId: string, text: string): Promise<void>;
29
+ addReaction(messageId: string, emojiType: string): Promise<string>;
30
+ removeReaction(messageId: string, reactionId: string): Promise<void>;
31
+ recallMessage(messageId: string): Promise<void>;
32
+ }
33
+
34
+ export interface ChannelCardStream {
35
+ update(card: object): Promise<void>;
36
+ finish(): Promise<void>;
18
37
  }
19
38
 
20
39
  export type ChannelFactory = (credentials: FeishuCredentials) => ChannelLike;
@@ -22,8 +41,10 @@ export type ChannelFactory = (credentials: FeishuCredentials) => ChannelLike;
22
41
  export class SdkFeishuGateway implements FeishuGateway {
23
42
  private readonly credentials: FeishuCredentials;
24
43
  private readonly channelFactory: ChannelFactory;
44
+ private readonly reactionHandlers = new Set<FeishuReactionHandler>();
25
45
  private channel: ChannelLike | undefined;
26
46
  private unsubscribe: (() => void) | undefined;
47
+ private reactionUnsubscribe: (() => void) | undefined;
27
48
 
28
49
  constructor(credentials: FeishuCredentials, channelFactory: ChannelFactory = createOfficialChannel) {
29
50
  this.credentials = credentials;
@@ -43,14 +64,20 @@ export class SdkFeishuGateway implements FeishuGateway {
43
64
  text: message.content,
44
65
  }),
45
66
  );
67
+ const reactionUnsubscribe = channel.onReaction((event) => {
68
+ for (const reactionHandler of this.reactionHandlers) reactionHandler(event);
69
+ });
46
70
  this.channel = channel;
47
71
  this.unsubscribe = unsubscribe;
72
+ this.reactionUnsubscribe = reactionUnsubscribe;
48
73
  try {
49
74
  await channel.connect();
50
75
  } catch (error) {
51
76
  this.channel = undefined;
52
77
  this.unsubscribe = undefined;
78
+ this.reactionUnsubscribe = undefined;
53
79
  unsubscribe();
80
+ reactionUnsubscribe();
54
81
  await channel.disconnect().catch(() => undefined);
55
82
  throw error;
56
83
  }
@@ -61,13 +88,52 @@ export class SdkFeishuGateway implements FeishuGateway {
61
88
  this.channel = undefined;
62
89
  this.unsubscribe?.();
63
90
  this.unsubscribe = undefined;
91
+ this.reactionUnsubscribe?.();
92
+ this.reactionUnsubscribe = undefined;
64
93
  if (channel) await channel.disconnect();
65
94
  }
66
95
 
67
- async sendText(chatId: string, text: string, replyTo?: string): Promise<void> {
96
+ onReaction(handler: FeishuReactionHandler): () => void {
97
+ this.reactionHandlers.add(handler);
98
+ return () => this.reactionHandlers.delete(handler);
99
+ }
100
+
101
+ async sendText(chatId: string, text: string, replyTo?: string): Promise<string | undefined> {
102
+ const channel = this.channel;
103
+ if (!channel) throw new Error("飞书长连接尚未启动。");
104
+ return channel.sendText(chatId, text, replyTo);
105
+ }
106
+
107
+ async addReaction(messageId: string, emojiType: string): Promise<string> {
108
+ const channel = this.channel;
109
+ if (!channel) throw new Error("飞书长连接尚未启动。");
110
+ return channel.addReaction(messageId, emojiType);
111
+ }
112
+
113
+ async removeReaction(messageId: string, reactionId: string): Promise<void> {
114
+ const channel = this.channel;
115
+ if (!channel) throw new Error("飞书长连接尚未启动。");
116
+ await channel.removeReaction(messageId, reactionId);
117
+ }
118
+
119
+ async recallMessage(messageId: string): Promise<void> {
120
+ const channel = this.channel;
121
+ if (!channel) throw new Error("飞书长连接尚未启动。");
122
+ await channel.recallMessage(messageId);
123
+ }
124
+
125
+ async editText(messageId: string, text: string): Promise<void> {
68
126
  const channel = this.channel;
69
127
  if (!channel) throw new Error("飞书长连接尚未启动。");
70
- await channel.sendText(chatId, text, replyTo);
128
+ await channel.editText(messageId, text);
129
+ }
130
+
131
+ async beginReply(chatId: string, replyTo: string): Promise<FeishuReply> {
132
+ const channel = this.channel;
133
+ if (!channel) throw new Error("飞书长连接尚未启动。");
134
+ const initial = { text: "", status: "正在思考" } satisfies FeishuReplySnapshot;
135
+ const stream = await channel.startCardStream(chatId, buildReplyCard(initial), replyTo);
136
+ return new SdkFeishuReply(stream, channel, chatId, replyTo, initial);
71
137
  }
72
138
  }
73
139
 
@@ -92,6 +158,10 @@ function createOfficialChannel(credentials: FeishuCredentials): ChannelLike {
92
158
  handshakeTimeoutMs: 10_000,
93
159
  loggerLevel: LoggerLevel.error,
94
160
  source: "pi-feishu",
161
+ outbound: {
162
+ streamThrottleMs: 500,
163
+ streamThrottleChars: 120,
164
+ },
95
165
  policy: {
96
166
  dmMode: "open",
97
167
  groupAllowlist: [],
@@ -115,6 +185,17 @@ class OfficialChannelAdapter implements ChannelLike {
115
185
  return this.channel.on("message", (message) => handler(toChannelMessage(message)));
116
186
  }
117
187
 
188
+ onReaction(handler: FeishuReactionHandler): () => void {
189
+ return this.channel.on("reaction", (event) =>
190
+ handler({
191
+ messageId: event.messageId,
192
+ operatorOpenId: event.operator.openId,
193
+ emojiType: event.emojiType,
194
+ action: event.action,
195
+ }),
196
+ );
197
+ }
198
+
118
199
  async connect(): Promise<void> {
119
200
  await this.channel.connect();
120
201
  }
@@ -123,9 +204,170 @@ class OfficialChannelAdapter implements ChannelLike {
123
204
  await this.channel.disconnect();
124
205
  }
125
206
 
126
- async sendText(to: string, text: string, replyTo?: string): Promise<void> {
127
- await this.channel.send(to, { text }, replyTo ? { replyTo } : undefined);
207
+ async sendText(to: string, text: string, replyTo?: string): Promise<string | undefined> {
208
+ const result = await this.channel.send(to, { text }, replyTo ? { replyTo } : undefined);
209
+ return result.messageId;
210
+ }
211
+
212
+ async editText(messageId: string, text: string): Promise<void> {
213
+ await this.channel.editMessage(messageId, text);
214
+ }
215
+
216
+ async addReaction(messageId: string, emojiType: string): Promise<string> {
217
+ return this.channel.addReaction(messageId, emojiType);
218
+ }
219
+
220
+ async removeReaction(messageId: string, reactionId: string): Promise<void> {
221
+ await this.channel.removeReaction(messageId, reactionId);
222
+ }
223
+
224
+ async recallMessage(messageId: string): Promise<void> {
225
+ await this.channel.recallMessage(messageId);
226
+ }
227
+
228
+ async startCardStream(to: string, card: object, replyTo?: string): Promise<ChannelCardStream> {
229
+ const stream = new OfficialChannelCardStream(this.channel, to, card, replyTo);
230
+ await stream.start();
231
+ return stream;
232
+ }
233
+ }
234
+
235
+ interface CardStreamControllerLike {
236
+ update(card: object): Promise<void>;
237
+ }
238
+
239
+ class OfficialChannelCardStream implements ChannelCardStream {
240
+ private controller: CardStreamControllerLike | undefined;
241
+ private resolveOpened: (() => void) | undefined;
242
+ private resolveProducer: (() => void) | undefined;
243
+ private readonly opened: Promise<void>;
244
+ private readonly producerCompleted: Promise<void>;
245
+ private readonly stream: Promise<void>;
246
+
247
+ constructor(channel: LarkChannel, to: string, card: object, replyTo?: string) {
248
+ this.opened = new Promise<void>((resolve) => {
249
+ this.resolveOpened = resolve;
250
+ });
251
+ this.producerCompleted = new Promise<void>((resolve) => {
252
+ this.resolveProducer = resolve;
253
+ });
254
+ this.stream = channel
255
+ .stream(
256
+ to,
257
+ {
258
+ card: {
259
+ initial: card,
260
+ producer: async (controller) => {
261
+ this.controller = controller;
262
+ this.resolveOpened?.();
263
+ await this.producerCompleted;
264
+ },
265
+ },
266
+ },
267
+ replyTo ? { replyTo } : undefined,
268
+ )
269
+ .then(() => undefined);
270
+ }
271
+
272
+ async start(): Promise<void> {
273
+ await Promise.race([this.opened, this.stream]);
274
+ }
275
+
276
+ async update(card: object): Promise<void> {
277
+ const controller = this.controller;
278
+ if (!controller) throw new Error("飞书流式回复尚未准备完成。");
279
+ await controller.update(card);
280
+ }
281
+
282
+ async finish(): Promise<void> {
283
+ this.resolveProducer?.();
284
+ await this.stream;
285
+ }
286
+ }
287
+
288
+ class SdkFeishuReply implements FeishuReply {
289
+ private snapshot: FeishuReplySnapshot;
290
+ private terminal = false;
291
+ private updateFailed = false;
292
+
293
+ constructor(
294
+ private readonly stream: ChannelCardStream,
295
+ private readonly channel: ChannelLike,
296
+ private readonly chatId: string,
297
+ private readonly replyTo: string,
298
+ initial: FeishuReplySnapshot,
299
+ ) {
300
+ this.snapshot = initial;
128
301
  }
302
+
303
+ update(snapshot: FeishuReplySnapshot): void {
304
+ if (this.terminal) return;
305
+ this.snapshot = snapshot;
306
+ void this.stream.update(buildReplyCard(snapshot)).catch(() => {
307
+ this.updateFailed = true;
308
+ });
309
+ }
310
+
311
+ async complete(snapshot: FeishuReplySnapshot): Promise<void> {
312
+ await this.finish(snapshot, snapshot.text || "Pi 已完成处理,但没有返回文本内容。");
313
+ }
314
+
315
+ async fail(): Promise<void> {
316
+ await this.finish(
317
+ { text: this.snapshot.text || "处理消息失败,请稍后再试。", status: "处理失败" },
318
+ "处理消息失败,请稍后再试。",
319
+ );
320
+ }
321
+
322
+ async cancel(): Promise<void> {
323
+ await this.finish({ text: this.snapshot.text || "任务已取消。", status: "已取消" }, "任务已取消。");
324
+ }
325
+
326
+ private async finish(snapshot: FeishuReplySnapshot, fallbackText: string): Promise<void> {
327
+ if (this.terminal) return;
328
+ this.terminal = true;
329
+ this.snapshot = snapshot;
330
+ try {
331
+ if (!this.updateFailed) await this.stream.update(buildReplyCard(snapshot));
332
+ await this.stream.finish();
333
+ } catch {
334
+ await this.channel.sendText(this.chatId, fallbackText, this.replyTo).catch(() => undefined);
335
+ }
336
+ }
337
+ }
338
+
339
+ export function buildReplyCard(snapshot: FeishuReplySnapshot): object {
340
+ const content = snapshot.text.trim() || statusPlaceholder(snapshot.status);
341
+ return {
342
+ config: { wide_screen_mode: true },
343
+ header: {
344
+ title: { tag: "plain_text", content: "Pi" },
345
+ template: headerTemplate(snapshot.status),
346
+ },
347
+ elements: [
348
+ { tag: "markdown", content },
349
+ {
350
+ tag: "note",
351
+ elements: [{ tag: "plain_text", content: snapshot.status }],
352
+ },
353
+ ],
354
+ };
355
+ }
356
+
357
+ function statusPlaceholder(status: FeishuReplySnapshot["status"]): string {
358
+ if (status === "正在执行工具") return "正在执行工具…";
359
+ if (status === "正在生成回复") return "正在生成回复…";
360
+ if (status === "已取消") return "任务已取消。";
361
+ if (status === "处理失败") return "处理消息失败,请稍后再试。";
362
+ if (status === "已完成") return "Pi 已完成处理,但没有返回文本内容。";
363
+ return "正在思考…";
364
+ }
365
+
366
+ function headerTemplate(status: FeishuReplySnapshot["status"]): "blue" | "green" | "red" | "grey" {
367
+ if (status === "已完成") return "green";
368
+ if (status === "处理失败") return "red";
369
+ if (status === "已取消") return "grey";
370
+ return "blue";
129
371
  }
130
372
 
131
373
  function toChannelMessage(message: NormalizedMessage): NormalizedChannelMessage {
@@ -1,9 +1,10 @@
1
- import type { AgentBridge } from "./contracts.js";
1
+ import type { AgentBridge, AgentProgressObserver } from "./contracts.js";
2
2
 
3
3
  interface PendingTurn {
4
4
  resolve: (text: string) => void;
5
5
  reject: (error: Error) => void;
6
6
  lastAssistantText: string;
7
+ observer: AgentProgressObserver | undefined;
7
8
  }
8
9
 
9
10
  export class PiAgentBridge implements AgentBridge {
@@ -14,13 +15,14 @@ export class PiAgentBridge implements AgentBridge {
14
15
  this.sendUserMessage = sendUserMessage;
15
16
  }
16
17
 
17
- run(text: string): Promise<string> {
18
+ run(text: string, observer?: AgentProgressObserver): Promise<string> {
18
19
  if (this.pending) {
19
20
  return Promise.reject(new Error("已有飞书消息正在等待 Pi 回复。"));
20
21
  }
21
22
 
22
23
  return new Promise<string>((resolve, reject) => {
23
- this.pending = { resolve, reject, lastAssistantText: "" };
24
+ this.pending = { resolve, reject, lastAssistantText: "", observer };
25
+ observer?.onActivity?.({ kind: "thinking" });
24
26
  try {
25
27
  this.sendUserMessage(text);
26
28
  } catch (error) {
@@ -31,12 +33,32 @@ export class PiAgentBridge implements AgentBridge {
31
33
  }
32
34
 
33
35
  captureMessage(message: unknown): void {
34
- if (!this.pending || !isAssistantMessage(message)) return;
36
+ this.captureAssistantText(message);
37
+ }
38
+
39
+ captureStreamingMessage(message: unknown): void {
40
+ this.captureAssistantText(message);
41
+ }
42
+
43
+ captureToolStart(toolName: string): void {
44
+ this.pending?.observer?.onActivity?.({ kind: "tool", toolName });
45
+ }
46
+
47
+ captureToolEnd(): void {
48
+ this.pending?.observer?.onActivity?.({ kind: "thinking" });
49
+ }
50
+
51
+ private captureAssistantText(message: unknown): void {
52
+ const pending = this.pending;
53
+ if (!pending || !isAssistantMessage(message)) return;
35
54
  const text = message.content
36
55
  .filter(isTextContent)
37
56
  .map((item) => item.text)
38
57
  .join("");
39
- if (text.trim()) this.pending.lastAssistantText = text.trim();
58
+ const normalized = text.trim();
59
+ if (!normalized || normalized === pending.lastAssistantText) return;
60
+ pending.lastAssistantText = normalized;
61
+ pending.observer?.onText?.(normalized);
40
62
  }
41
63
 
42
64
  settle(): void {
@@ -0,0 +1,142 @@
1
+ import type { FeishuStatus, PiRuntime, PiRuntimeSnapshot } from "./contracts.js";
2
+
3
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
4
+
5
+ export const REMOTE_SLASH_COMMANDS: Record<string, string> = {
6
+ "/help": "显示远程命令帮助",
7
+ "/new": "新建 Pi 会话(丢弃当前对话上下文)",
8
+ "/stop": "中止当前任务,并跳过排队中的消息",
9
+ "/status": "查看飞书连接与 Pi 运行状态",
10
+ "/compact": "压缩当前会话上下文",
11
+ "/thinking": "查看或设置思考档位:/thinking [off|minimal|low|medium|high|xhigh|max]",
12
+ "/model": "查看当前模型,或切换:/model <模型ID或名称>",
13
+ };
14
+
15
+ export interface ParsedRemoteCommand {
16
+ name: string;
17
+ args: string;
18
+ }
19
+
20
+ export interface RemoteCommandContext {
21
+ runtime: PiRuntime | undefined;
22
+ status: () => Promise<FeishuStatus>;
23
+ /** Aborts queued messages after the current one; returns how many were skipped. */
24
+ stopQueue?: () => number;
25
+ }
26
+
27
+ export function parseRemoteCommand(input: string): ParsedRemoteCommand | undefined {
28
+ const trimmed = input.trim();
29
+ if (!trimmed.startsWith("/")) return undefined;
30
+ const separator = trimmed.search(/\s/);
31
+ const name = separator === -1 ? trimmed : trimmed.slice(0, separator);
32
+ const args = separator === -1 ? "" : trimmed.slice(separator + 1).trim();
33
+ return { name: name.toLowerCase(), args };
34
+ }
35
+
36
+ export function renderRemoteHelp(): string {
37
+ const lines = Object.entries(REMOTE_SLASH_COMMANDS).map(([name, description]) => ` ${name} — ${description}`);
38
+ return ["飞书远程命令:", ...lines, "", "其余消息会直接发送给当前 Pi 会话处理。"].join("\n");
39
+ }
40
+
41
+ export function renderRemoteStatus(status: FeishuStatus, snapshot: PiRuntimeSnapshot | undefined): string {
42
+ const lines = ["飞书状态", ` 连接:${status.running ? "已连接" : "未连接"}`, ` 队列:${status.pendingMessages}`];
43
+ if (status.configured) {
44
+ lines.push(` Owner:${status.ownerOpenId ? "已绑定" : "未绑定"}`);
45
+ lines.push("Pi 状态");
46
+ lines.push(` 模型:${snapshot?.model ?? "未知"}`);
47
+ lines.push(` 思考:${snapshot?.thinkingLevel ?? "未知"}`);
48
+ lines.push(` 上下文:${formatContextPercent(snapshot?.contextPercent)}`);
49
+ lines.push(` 运行:${snapshot?.streaming ? "运行中" : "空闲"}`);
50
+ }
51
+ return lines.join("\n");
52
+ }
53
+
54
+ export async function executeRemoteCommand(input: string, context: RemoteCommandContext): Promise<string> {
55
+ const command = parseRemoteCommand(input);
56
+ if (!command) return unknownCommandReply(input);
57
+ switch (command.name) {
58
+ case "/help":
59
+ return renderRemoteHelp();
60
+ case "/status": {
61
+ const status = await context.status();
62
+ return renderRemoteStatus(status, context.runtime?.snapshot());
63
+ }
64
+ case "/stop":
65
+ return executeStop(context);
66
+ case "/compact":
67
+ return executeCompact(context.runtime);
68
+ case "/new":
69
+ return executeNewSession(context.runtime);
70
+ case "/thinking":
71
+ return executeThinking(command.args, context.runtime);
72
+ case "/model":
73
+ return executeModel(command.args, context.runtime);
74
+ default:
75
+ return unknownCommandReply(command.name);
76
+ }
77
+ }
78
+
79
+ function executeStop(context: RemoteCommandContext): string {
80
+ const runtime = context.runtime;
81
+ const running = Boolean(runtime && !runtime.isIdle());
82
+ if (running) runtime?.abort();
83
+ const skipped = context.stopQueue?.() ?? 0;
84
+ if (!running && skipped === 0) return "当前没有正在运行的 Pi 任务。";
85
+ const suffix = skipped > 0 ? `,并跳过队列中的 ${skipped} 条消息` : "";
86
+ return `已发送中止信号${suffix}。`;
87
+ }
88
+
89
+ function executeCompact(runtime: PiRuntime | undefined): string {
90
+ if (!runtime) return "Pi 运行时尚未就绪,请稍后再试。";
91
+ runtime.compact();
92
+ return "已开始压缩上下文,完成后继续对话即可。";
93
+ }
94
+
95
+ async function executeNewSession(runtime: PiRuntime | undefined): Promise<string> {
96
+ if (!runtime) return "远程新建会话暂不可用:请先在本地 Pi 执行任意 /feishu 命令,再通过飞书发送 /new。";
97
+ const created = await runtime.newSession();
98
+ return created
99
+ ? "已新建 Pi 会话,接下来是一个全新的对话。"
100
+ : "新建会话未完成(本地已取消或暂不可用)。请在本地 Pi 执行 /feishu 命令后重试。";
101
+ }
102
+
103
+ async function executeThinking(args: string, runtime: PiRuntime | undefined): Promise<string> {
104
+ const levels = THINKING_LEVELS.join(" / ");
105
+ if (!args) {
106
+ const current = runtime?.snapshot().thinkingLevel;
107
+ const line = current ? `当前思考档位:${current}` : "当前思考档位未知。";
108
+ return `${line}\n可用档位:${levels}\n示例:/thinking high`;
109
+ }
110
+ if (!runtime) return "Pi 运行时尚未就绪,请稍后再试。";
111
+ const level = args.toLowerCase();
112
+ const applied = await runtime.setThinkingLevel(level);
113
+ return applied ? `已切换思考档位:${level}。` : `无法设置思考档位“${args}”。\n可用档位:${levels}`;
114
+ }
115
+
116
+ async function executeModel(args: string, runtime: PiRuntime | undefined): Promise<string> {
117
+ if (!runtime) return "Pi 运行时尚未就绪,请稍后再试。";
118
+ if (!args) {
119
+ const current = runtime.snapshot().model;
120
+ const models = runtime.listModels();
121
+ const hint =
122
+ models.length > 0
123
+ ? `共 ${models.length} 个可用模型,使用 /model <模型ID或名称> 切换。`
124
+ : "使用 /model <模型ID或名称> 切换。";
125
+ return current ? `当前模型:${current}\n${hint}` : `当前模型未知。\n${hint}`;
126
+ }
127
+ try {
128
+ const model = await runtime.switchModel(args);
129
+ return `已切换模型:${model.name}(${model.id})。`;
130
+ } catch (error) {
131
+ return `切换模型失败:${error instanceof Error ? error.message : String(error)}`;
132
+ }
133
+ }
134
+
135
+ function unknownCommandReply(input: string): string {
136
+ return `❓ 未知命令:${input.trim().split(/\s+/)[0]}\n\n输入 /help 查看可用命令。`;
137
+ }
138
+
139
+ function formatContextPercent(percent: number | undefined): string {
140
+ if (percent === undefined || percent === null || Number.isNaN(percent)) return "未知";
141
+ return `${Math.round(percent)}%`;
142
+ }