@hwj123weijian/pi-feishu 0.2.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
@@ -8,14 +8,18 @@
8
8
 
9
9
  - 仅处理飞书私聊(`p2p`)文本消息
10
10
  - 首次启动生成一次性绑定码,只允许一个 Owner
11
+ - 收到消息后立刻在原消息上添加“思考中”表情作为已读回执,回复送达后自动移除
12
+ - 以 `/` 开头的消息走远程命令快速通道,不排队、不进入 LLM 上下文
11
13
  - 所有消息进入当前 Pi 会话,不创建额外 Agent 或会话
14
+ - 消息需要排队时发送“排队中(第 N 位)”提示,轮到处理时自动撤回;队列变化时提示自动改写为新位数
15
+ - Owner 在排队中的消息上点 ❌(`CrossMark`)表情即可取消该消息
12
16
  - Assistant 文本通过 Pi 的 `message_update` 增量渲染到同一张飞书卡片
13
17
  - 工具执行时仅显示安全的状态摘要,不展示参数、命令、文件内容或工具输出
14
18
  - SDK 以 500ms / 120 字符节流卡片更新;完成、失败、中止都会收尾卡片
15
19
  - 缺少更新权限或飞书卡片更新失败时,自动退化为原私聊的一次性文本回复
16
20
  - 串行处理消息,避免多条飞书消息同时驱动 Pi
17
21
  - 按飞书 `message_id` 去重
18
- - `/feishu stop`、`/feishu logout` 和 Pi 会话关闭时释放长连接
22
+ - 长连接由跨会话的全局控制器保管:本地 `/new`、远程 `/new` 等会话切换不会断开;`/feishu stop`、`/feishu logout` 或 Pi 进程退出时释放
19
23
  - 凭据优先从环境变量读取,也可保存到本地凭据文件
20
24
 
21
25
  当前不支持群聊、图片、文件、卡片、语音、多用户、多会话、进程级沙箱或远程命令权限管理。这里的安全边界是“私聊 + 单 Owner”,Pi 本身仍拥有当前本地进程的权限。
@@ -35,11 +39,12 @@
35
39
  - `im:message.p2p_msg:readonly`:接收私聊消息
36
40
  - `im:message:send_as_bot`:以机器人身份回复
37
41
  - `im:message:update`:持续更新机器人发出的交互卡片
42
+ - 表情回复相关权限:在原消息上添加/移除“思考中”已读回执
38
43
  3. 在“事件与回调”中选择“使用长连接接收事件”。
39
44
  4. 添加事件 `im.message.receive_v1`。
40
45
  5. 创建并发布一个应用版本,使权限和事件订阅在企业内生效。
41
46
 
42
- 这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。若未授予 `im:message:update`,扩展仍可工作,但会降级为最终文本一次性回复。
47
+ 这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。若未授予 `im:message:update`,扩展仍可工作,但会降级为最终文本一次性回复。若未授予表情回复权限,扩展同样可工作,只是原消息上不会出现已读表情。
43
48
 
44
49
  ## 安装
45
50
 
@@ -93,7 +98,7 @@ pi -e D:\ai_study\pi-feishu
93
98
  /bind 123456
94
99
  ```
95
100
 
96
- 绑定成功后,直接私聊机器人即可驱动当前 Pi 会话。处理过程中会先显示“正在思考”,随后持续更新正文;Pi 调用工具时卡片会显示“正在执行工具”,最终状态会变为“已完成”。
101
+ 绑定成功后,直接私聊机器人即可驱动当前 Pi 会话。原消息会立刻出现“思考中”表情(已读回执),处理过程中先显示“正在思考”,随后持续更新正文;Pi 调用工具时卡片会显示“正在执行工具”,最终状态变为“已完成”,同时已读表情自动移除。
97
102
 
98
103
  也支持手动参数:
99
104
 
@@ -122,6 +127,28 @@ pi -e D:\ai_study\pi-feishu
122
127
 
123
128
  环境变量优先于凭据文件。如果环境变量中的 App ID 与已保存的 App ID 不同,旧 Owner 绑定不会被继承。
124
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
+
125
152
  ## 常见问题
126
153
 
127
154
  ### `setup` 或 `start` 连接失败
@@ -140,6 +167,14 @@ pi -e D:\ai_study\pi-feishu
140
167
 
141
168
  确认已申请并发布 `im:message:update`。卡片初始化或更新失败时,扩展会自动改为最终文本回复,避免用户没有任何反馈。
142
169
 
170
+ ### 没有已读表情
171
+
172
+ 确认已申请并发布表情回复相关权限。该权限缺失时扩展自动降级:消息照常收发,只是原消息上不会出现“思考中”表情。
173
+
174
+ ### 远程 `/new` 提示暂不可用
175
+
176
+ 远程会话切换依赖本地 Pi 提供的命令上下文。先在本地 Pi 执行任意 `/feishu` 子命令(例如 `/feishu status`),再从飞书发送 `/new`。切换完成后长连接自动延续,无需重新执行 `/feishu start`。
177
+
143
178
  ### Bot 提示“未授权”
144
179
 
145
180
  该 Bot 已绑定其他 Owner。若要重新绑定,先在本地 Pi 执行 `/feishu logout`,再重新 `setup`、`start` 和 `/bind`。
@@ -156,7 +191,7 @@ npm test
156
191
  npm run build
157
192
  ```
158
193
 
159
- 测试使用 Fake Gateway 和 Fake Agent,不需要真实飞书凭据,覆盖凭据处理、Owner 绑定、私聊过滤、串行队列、去重、Pi 增量文本、工具状态、流式卡片收尾、降级、错误脱敏和清理行为。
194
+ 测试使用 Fake Gateway 和 Fake Agent,不需要真实飞书凭据,覆盖凭据处理、Owner 绑定、私聊过滤、串行队列、排队提示改位与撤回、❌ 取消、`/stop` 按条跳过、去重、已读表情回执、远程命令快速通道、思考档位与模型切换、Pi 增量文本、工具状态、流式卡片收尾、降级、错误脱敏和清理行为。
160
195
 
161
196
  ## 许可证
162
197
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwj123weijian/pi-feishu",
3
- "version": "0.2.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,6 +21,15 @@ 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
+
24
33
  export interface FeishuReplySnapshot {
25
34
  text: string;
26
35
  status: "正在思考" | "正在生成回复" | "正在执行工具" | "已完成" | "处理失败" | "已取消";
@@ -36,8 +45,59 @@ export interface FeishuReply {
36
45
  export interface FeishuGateway {
37
46
  connect(handler: FeishuMessageHandler): Promise<void>;
38
47
  disconnect(): Promise<void>;
39
- 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>;
40
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;
41
101
  }
42
102
 
43
103
  export type AgentActivity = { kind: "thinking" } | { kind: "tool"; toolName: string };
package/src/controller.ts CHANGED
@@ -7,18 +7,40 @@ import type {
7
7
  FeishuGateway,
8
8
  FeishuGatewayFactory,
9
9
  FeishuIncomingMessage,
10
+ FeishuReactionEvent,
10
11
  FeishuReply,
12
+ FeishuStatus,
13
+ PiRuntime,
11
14
  } from "./contracts.js";
12
15
  import { CredentialError, errorMessage, resolveCredentialInput, resolveRuntimeCredentials } from "./credentials.js";
13
16
  import { MessageDeduplicator } from "./message-deduplicator.js";
14
17
  import { SerialMessageQueue } from "./message-queue.js";
15
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
+ }
16
37
 
17
38
  export interface FeishuControllerOptions {
18
39
  store: CredentialStore;
19
40
  gatewayFactory: FeishuGatewayFactory;
20
41
  validateCredentials: CredentialValidator;
21
42
  agent: AgentBridge;
43
+ runtime?: PiRuntime;
22
44
  generateBindingCode?: () => string;
23
45
  }
24
46
 
@@ -27,26 +49,20 @@ export interface FeishuStartResult {
27
49
  bindingCode?: string;
28
50
  }
29
51
 
30
- export interface FeishuStatus {
31
- configured: boolean;
32
- running: boolean;
33
- ownerOpenId?: string;
34
- appId?: string;
35
- source?: "environment" | "file";
36
- pendingMessages: number;
37
- }
38
-
39
52
  export class FeishuController {
40
53
  private readonly store: CredentialStore;
41
54
  private readonly gatewayFactory: FeishuGatewayFactory;
42
55
  private readonly validateCredentials: CredentialValidator;
43
56
  private readonly agent: AgentBridge;
57
+ private readonly runtime: PiRuntime | undefined;
44
58
  private readonly generateBindingCode: (() => string) | undefined;
45
59
  private readonly queue = new SerialMessageQueue();
46
60
  private readonly deduplicator = new MessageDeduplicator();
47
61
  private gateway: FeishuGateway | undefined;
48
62
  private credentials: FeishuCredentials | undefined;
49
63
  private binding: OwnerBinding | undefined;
64
+ private environment: Environment = {};
65
+ private readonly pendingTasks = new Map<string, PendingTask>();
50
66
  private readonly activeReplies = new Set<FeishuReply>();
51
67
 
52
68
  constructor(options: FeishuControllerOptions) {
@@ -54,6 +70,7 @@ export class FeishuController {
54
70
  this.gatewayFactory = options.gatewayFactory;
55
71
  this.validateCredentials = options.validateCredentials;
56
72
  this.agent = options.agent;
73
+ this.runtime = options.runtime;
57
74
  this.generateBindingCode = options.generateBindingCode;
58
75
  }
59
76
 
@@ -91,6 +108,7 @@ export class FeishuController {
91
108
  this.credentials = credentials;
92
109
  this.binding = binding;
93
110
  this.gateway = gateway;
111
+ this.environment = environment;
94
112
  this.deduplicator.clear();
95
113
 
96
114
  try {
@@ -101,6 +119,7 @@ export class FeishuController {
101
119
  await gateway.disconnect().catch(() => undefined);
102
120
  throw new CredentialError(`启动飞书长连接失败:${errorMessage(error, credentials)}`);
103
121
  }
122
+ gateway.onReaction((event) => this.handleReaction(event));
104
123
 
105
124
  const bindingCode = binding.getOrCreateCode();
106
125
  return bindingCode ? { alreadyRunning: false, bindingCode } : { alreadyRunning: false };
@@ -109,6 +128,8 @@ export class FeishuController {
109
128
  async stop(): Promise<boolean> {
110
129
  const gateway = this.gateway;
111
130
  if (!gateway) return false;
131
+ for (const entry of this.pendingTasks.values()) entry.cancelled = true;
132
+ this.pendingTasks.clear();
112
133
  await Promise.all([...this.activeReplies].map((reply) => reply.cancel().catch(() => undefined)));
113
134
  this.activeReplies.clear();
114
135
  this.gateway = undefined;
@@ -149,60 +170,139 @@ export class FeishuController {
149
170
  if (!gateway || !binding || message.chatType !== "p2p" || message.contentType !== "text") return;
150
171
  if (!message.messageId || !this.deduplicator.accept(message.messageId)) return;
151
172
 
173
+ const reactionId = await this.ackRead(gateway, message.messageId);
152
174
  const authorization = binding.authorize(message.senderOpenId, message.text);
153
175
  switch (authorization.kind) {
154
176
  case "binding-required":
155
- await this.safeSend(gateway, message, "Bot 尚未绑定,请在本地 Pi 查看一次性绑定码。");
177
+ await this.acknowledge(gateway, message, reactionId, "Bot 尚未绑定,请在本地 Pi 查看一次性绑定码。");
156
178
  return;
157
179
  case "invalid-binding-code":
158
- await this.safeSend(gateway, message, "绑定码无效,请检查本地 Pi 显示的一次性绑定码。");
180
+ await this.acknowledge(gateway, message, reactionId, "绑定码无效,请检查本地 Pi 显示的一次性绑定码。");
159
181
  return;
160
182
  case "unauthorized":
161
- await this.safeSend(gateway, message, "未授权:此 Bot 仅响应已绑定的 Owner。");
183
+ await this.acknowledge(gateway, message, reactionId, "未授权:此 Bot 仅响应已绑定的 Owner。");
162
184
  return;
163
185
  case "bound":
164
186
  await this.persistOwner(authorization.ownerOpenId);
165
- await this.safeSend(gateway, message, "绑定成功,现在可以直接发送问题。");
187
+ await this.acknowledge(gateway, message, reactionId, "绑定成功,现在可以直接发送问题。");
166
188
  return;
167
189
  case "authorized":
168
- void this.queue.enqueue(async () => {
169
- if (this.gateway !== gateway) return;
170
- const reply = await gateway.beginReply(message.chatId, message.messageId).catch(() => undefined);
171
- if (reply) this.activeReplies.add(reply);
172
- let latestText = "";
173
- try {
174
- const response = await this.agent.run(authorization.text, {
175
- onText: (text) => {
176
- latestText = text;
177
- reply?.update({ text, status: "正在生成回复" });
178
- },
179
- onActivity: (activity) => {
180
- reply?.update({
181
- text: latestText,
182
- status: activity.kind === "tool" ? "正在执行工具" : "正在思考",
183
- });
184
- },
185
- });
186
- if (this.gateway === gateway) {
187
- if (reply) {
188
- await reply.complete({ text: response, status: "已完成" });
189
- } else {
190
- await gateway.sendText(message.chatId, response, message.messageId);
191
- }
192
- }
193
- } catch {
194
- if (this.gateway === gateway) {
195
- if (reply) {
196
- await reply.fail();
197
- } else {
198
- await this.safeSend(gateway, message, "处理消息失败,请稍后再试。");
199
- }
200
- }
201
- } finally {
202
- if (reply) this.activeReplies.delete(reply);
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;
203
228
  }
204
- });
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);
205
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);
206
306
  }
207
307
 
208
308
  async waitForIdle(): Promise<void> {
@@ -221,6 +321,82 @@ export class FeishuController {
221
321
  this.credentials = next;
222
322
  }
223
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
+
224
400
  private async safeSend(gateway: FeishuGateway, message: FeishuIncomingMessage, text: string): Promise<void> {
225
401
  try {
226
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,46 +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;
134
+
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 };
77
184
 
78
- pi.on("message_end", (event) => {
79
- agent.captureMessage(event.message);
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);
80
194
  });
81
- pi.on("message_update", (event) => {
82
- agent.captureStreamingMessage(event.message);
195
+ pi.on("message_update", (event, context) => {
196
+ trackContext(event, context);
197
+ bridge.captureStreamingMessage(event.message);
83
198
  });
84
- pi.on("tool_execution_start", (event) => {
85
- agent.captureToolStart(event.toolName);
199
+ pi.on("tool_execution_start", (event, context) => {
200
+ trackContext(event, context);
201
+ bridge.captureToolStart(event.toolName);
86
202
  });
87
- pi.on("tool_execution_end", () => {
88
- agent.captureToolEnd();
203
+ pi.on("tool_execution_end", (event, context) => {
204
+ trackContext(event, context);
205
+ bridge.captureToolEnd();
89
206
  });
90
- pi.on("agent_settled", () => {
91
- agent.settle();
207
+ pi.on("agent_settled", (event, context) => {
208
+ trackContext(event, context);
209
+ bridge.settle();
92
210
  });
93
- pi.on("session_shutdown", async () => {
94
- await controller.stop();
95
- agent.cancel("Pi 会话已关闭。");
211
+ pi.on("session_shutdown", () => {
212
+ // 只解绑本会话的桥:长连接归全局 controller 保管,本地 /new 切换会话后自动延续。
213
+ bridge.cancel("Pi 会话已关闭。");
96
214
  });
97
215
 
98
216
  pi.registerCommand("feishu", {
99
217
  description: "配置和管理飞书私聊连接",
100
218
  handler: async (args, context) => {
219
+ latestCommandContext = context;
101
220
  try {
102
- await handleFeishuCommand(parseFeishuCommand(args), controller, context);
221
+ await handleFeishuCommand(parseFeishuCommand(args), state.controller, context);
103
222
  } catch (error) {
104
- context.ui.notify(`飞书操作失败:${controller.sanitizeError(error)}`, "error");
223
+ context.ui.notify(`飞书操作失败:${state.controller.sanitizeError(error)}`, "error");
105
224
  }
106
225
  },
107
226
  });
108
227
  }
109
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
+
110
275
  async function handleFeishuCommand(
111
276
  command: ParsedFeishuCommand,
112
277
  controller: FeishuController,
package/src/gateway.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  FeishuCredentials,
4
4
  FeishuGateway,
5
5
  FeishuMessageHandler,
6
+ FeishuReactionHandler,
6
7
  FeishuReply,
7
8
  FeishuReplySnapshot,
8
9
  } from "./contracts.js";
@@ -18,10 +19,16 @@ export interface NormalizedChannelMessage {
18
19
 
19
20
  export interface ChannelLike {
20
21
  onMessage(handler: (message: NormalizedChannelMessage) => Promise<void> | void): () => void;
22
+ onReaction(handler: FeishuReactionHandler): () => void;
21
23
  connect(): Promise<void>;
22
24
  disconnect(): Promise<void>;
23
- 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>;
24
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>;
25
32
  }
26
33
 
27
34
  export interface ChannelCardStream {
@@ -34,8 +41,10 @@ export type ChannelFactory = (credentials: FeishuCredentials) => ChannelLike;
34
41
  export class SdkFeishuGateway implements FeishuGateway {
35
42
  private readonly credentials: FeishuCredentials;
36
43
  private readonly channelFactory: ChannelFactory;
44
+ private readonly reactionHandlers = new Set<FeishuReactionHandler>();
37
45
  private channel: ChannelLike | undefined;
38
46
  private unsubscribe: (() => void) | undefined;
47
+ private reactionUnsubscribe: (() => void) | undefined;
39
48
 
40
49
  constructor(credentials: FeishuCredentials, channelFactory: ChannelFactory = createOfficialChannel) {
41
50
  this.credentials = credentials;
@@ -55,14 +64,20 @@ export class SdkFeishuGateway implements FeishuGateway {
55
64
  text: message.content,
56
65
  }),
57
66
  );
67
+ const reactionUnsubscribe = channel.onReaction((event) => {
68
+ for (const reactionHandler of this.reactionHandlers) reactionHandler(event);
69
+ });
58
70
  this.channel = channel;
59
71
  this.unsubscribe = unsubscribe;
72
+ this.reactionUnsubscribe = reactionUnsubscribe;
60
73
  try {
61
74
  await channel.connect();
62
75
  } catch (error) {
63
76
  this.channel = undefined;
64
77
  this.unsubscribe = undefined;
78
+ this.reactionUnsubscribe = undefined;
65
79
  unsubscribe();
80
+ reactionUnsubscribe();
66
81
  await channel.disconnect().catch(() => undefined);
67
82
  throw error;
68
83
  }
@@ -73,13 +88,44 @@ export class SdkFeishuGateway implements FeishuGateway {
73
88
  this.channel = undefined;
74
89
  this.unsubscribe?.();
75
90
  this.unsubscribe = undefined;
91
+ this.reactionUnsubscribe?.();
92
+ this.reactionUnsubscribe = undefined;
76
93
  if (channel) await channel.disconnect();
77
94
  }
78
95
 
79
- 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> {
80
126
  const channel = this.channel;
81
127
  if (!channel) throw new Error("飞书长连接尚未启动。");
82
- await channel.sendText(chatId, text, replyTo);
128
+ await channel.editText(messageId, text);
83
129
  }
84
130
 
85
131
  async beginReply(chatId: string, replyTo: string): Promise<FeishuReply> {
@@ -139,6 +185,17 @@ class OfficialChannelAdapter implements ChannelLike {
139
185
  return this.channel.on("message", (message) => handler(toChannelMessage(message)));
140
186
  }
141
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
+
142
199
  async connect(): Promise<void> {
143
200
  await this.channel.connect();
144
201
  }
@@ -147,8 +204,25 @@ class OfficialChannelAdapter implements ChannelLike {
147
204
  await this.channel.disconnect();
148
205
  }
149
206
 
150
- async sendText(to: string, text: string, replyTo?: string): Promise<void> {
151
- 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);
152
226
  }
153
227
 
154
228
  async startCardStream(to: string, card: object, replyTo?: string): Promise<ChannelCardStream> {
@@ -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
+ }