@hwj123weijian/pi-feishu 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
 
@@ -9,6 +9,10 @@
9
9
  - 仅处理飞书私聊(`p2p`)文本消息
10
10
  - 首次启动生成一次性绑定码,只允许一个 Owner
11
11
  - 所有消息进入当前 Pi 会话,不创建额外 Agent 或会话
12
+ - Assistant 文本通过 Pi 的 `message_update` 增量渲染到同一张飞书卡片
13
+ - 工具执行时仅显示安全的状态摘要,不展示参数、命令、文件内容或工具输出
14
+ - SDK 以 500ms / 120 字符节流卡片更新;完成、失败、中止都会收尾卡片
15
+ - 缺少更新权限或飞书卡片更新失败时,自动退化为原私聊的一次性文本回复
12
16
  - 串行处理消息,避免多条飞书消息同时驱动 Pi
13
17
  - 按飞书 `message_id` 去重
14
18
  - `/feishu stop`、`/feishu logout` 和 Pi 会话关闭时释放长连接
@@ -30,11 +34,12 @@
30
34
  2. 在“权限管理”中申请:
31
35
  - `im:message.p2p_msg:readonly`:接收私聊消息
32
36
  - `im:message:send_as_bot`:以机器人身份回复
37
+ - `im:message:update`:持续更新机器人发出的交互卡片
33
38
  3. 在“事件与回调”中选择“使用长连接接收事件”。
34
39
  4. 添加事件 `im.message.receive_v1`。
35
40
  5. 创建并发布一个应用版本,使权限和事件订阅在企业内生效。
36
41
 
37
- 这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。
42
+ 这个扩展不需要公网回调地址,也不需要加密密钥或 Verification Token。若未授予 `im:message:update`,扩展仍可工作,但会降级为最终文本一次性回复。
38
43
 
39
44
  ## 安装
40
45
 
@@ -88,7 +93,7 @@ pi -e D:\ai_study\pi-feishu
88
93
  /bind 123456
89
94
  ```
90
95
 
91
- 绑定成功后,直接私聊机器人即可驱动当前 Pi 会话。
96
+ 绑定成功后,直接私聊机器人即可驱动当前 Pi 会话。处理过程中会先显示“正在思考”,随后持续更新正文;Pi 调用工具时卡片会显示“正在执行工具”,最终状态会变为“已完成”。
92
97
 
93
98
  也支持手动参数:
94
99
 
@@ -131,6 +136,10 @@ pi -e D:\ai_study\pi-feishu
131
136
 
132
137
  确认已申请并发布 `im:message:send_as_bot` 权限。
133
138
 
139
+ ### 只能收到最终文本,没有流式卡片
140
+
141
+ 确认已申请并发布 `im:message:update`。卡片初始化或更新失败时,扩展会自动改为最终文本回复,避免用户没有任何反馈。
142
+
134
143
  ### Bot 提示“未授权”
135
144
 
136
145
  该 Bot 已绑定其他 Owner。若要重新绑定,先在本地 Pi 执行 `/feishu logout`,再重新 `setup`、`start` 和 `/bind`。
@@ -147,7 +156,7 @@ npm test
147
156
  npm run build
148
157
  ```
149
158
 
150
- 测试使用 Fake Gateway 和 Fake Agent,不需要真实飞书凭据,覆盖凭据处理、Owner 绑定、私聊过滤、串行队列、去重、完整消息链路、错误脱敏和清理行为。
159
+ 测试使用 Fake Gateway 和 Fake Agent,不需要真实飞书凭据,覆盖凭据处理、Owner 绑定、私聊过滤、串行队列、去重、Pi 增量文本、工具状态、流式卡片收尾、降级、错误脱敏和清理行为。
151
160
 
152
161
  ## 许可证
153
162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hwj123weijian/pi-feishu",
3
- "version": "0.1.0",
3
+ "version": "0.2.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,34 @@ export interface FeishuIncomingMessage {
21
21
 
22
22
  export type FeishuMessageHandler = (message: FeishuIncomingMessage) => Promise<void> | void;
23
23
 
24
+ export interface FeishuReplySnapshot {
25
+ text: string;
26
+ status: "正在思考" | "正在生成回复" | "正在执行工具" | "已完成" | "处理失败" | "已取消";
27
+ }
28
+
29
+ export interface FeishuReply {
30
+ update(snapshot: FeishuReplySnapshot): void;
31
+ complete(snapshot: FeishuReplySnapshot): Promise<void>;
32
+ fail(): Promise<void>;
33
+ cancel(): Promise<void>;
34
+ }
35
+
24
36
  export interface FeishuGateway {
25
37
  connect(handler: FeishuMessageHandler): Promise<void>;
26
38
  disconnect(): Promise<void>;
27
39
  sendText(chatId: string, text: string, replyTo?: string): Promise<void>;
40
+ beginReply(chatId: string, replyTo: string): Promise<FeishuReply>;
41
+ }
42
+
43
+ export type AgentActivity = { kind: "thinking" } | { kind: "tool"; toolName: string };
44
+
45
+ export interface AgentProgressObserver {
46
+ onText?: (text: string) => void;
47
+ onActivity?: (activity: AgentActivity) => void;
28
48
  }
29
49
 
30
50
  export interface AgentBridge {
31
- run(text: string): Promise<string>;
51
+ run(text: string, observer?: AgentProgressObserver): Promise<string>;
32
52
  cancel(reason?: string): void;
33
53
  }
34
54
 
package/src/controller.ts CHANGED
@@ -7,6 +7,7 @@ import type {
7
7
  FeishuGateway,
8
8
  FeishuGatewayFactory,
9
9
  FeishuIncomingMessage,
10
+ FeishuReply,
10
11
  } from "./contracts.js";
11
12
  import { CredentialError, errorMessage, resolveCredentialInput, resolveRuntimeCredentials } from "./credentials.js";
12
13
  import { MessageDeduplicator } from "./message-deduplicator.js";
@@ -46,6 +47,7 @@ export class FeishuController {
46
47
  private gateway: FeishuGateway | undefined;
47
48
  private credentials: FeishuCredentials | undefined;
48
49
  private binding: OwnerBinding | undefined;
50
+ private readonly activeReplies = new Set<FeishuReply>();
49
51
 
50
52
  constructor(options: FeishuControllerOptions) {
51
53
  this.store = options.store;
@@ -107,6 +109,8 @@ export class FeishuController {
107
109
  async stop(): Promise<boolean> {
108
110
  const gateway = this.gateway;
109
111
  if (!gateway) return false;
112
+ await Promise.all([...this.activeReplies].map((reply) => reply.cancel().catch(() => undefined)));
113
+ this.activeReplies.clear();
110
114
  this.gateway = undefined;
111
115
  this.binding = undefined;
112
116
  this.agent.cancel("飞书连接已停止。");
@@ -163,15 +167,39 @@ export class FeishuController {
163
167
  case "authorized":
164
168
  void this.queue.enqueue(async () => {
165
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 = "";
166
173
  try {
167
- const response = await this.agent.run(authorization.text);
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
+ });
168
186
  if (this.gateway === gateway) {
169
- await gateway.sendText(message.chatId, response, message.messageId);
187
+ if (reply) {
188
+ await reply.complete({ text: response, status: "已完成" });
189
+ } else {
190
+ await gateway.sendText(message.chatId, response, message.messageId);
191
+ }
170
192
  }
171
193
  } catch {
172
194
  if (this.gateway === gateway) {
173
- await this.safeSend(gateway, message, "处理消息失败,请稍后再试。");
195
+ if (reply) {
196
+ await reply.fail();
197
+ } else {
198
+ await this.safeSend(gateway, message, "处理消息失败,请稍后再试。");
199
+ }
174
200
  }
201
+ } finally {
202
+ if (reply) this.activeReplies.delete(reply);
175
203
  }
176
204
  });
177
205
  }
package/src/extension.ts CHANGED
@@ -78,6 +78,15 @@ export default function feishuExtension(pi: ExtensionAPI): void {
78
78
  pi.on("message_end", (event) => {
79
79
  agent.captureMessage(event.message);
80
80
  });
81
+ pi.on("message_update", (event) => {
82
+ agent.captureStreamingMessage(event.message);
83
+ });
84
+ pi.on("tool_execution_start", (event) => {
85
+ agent.captureToolStart(event.toolName);
86
+ });
87
+ pi.on("tool_execution_end", () => {
88
+ agent.captureToolEnd();
89
+ });
81
90
  pi.on("agent_settled", () => {
82
91
  agent.settle();
83
92
  });
package/src/gateway.ts CHANGED
@@ -1,5 +1,11 @@
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
+ FeishuReply,
7
+ FeishuReplySnapshot,
8
+ } from "./contracts.js";
3
9
 
4
10
  export interface NormalizedChannelMessage {
5
11
  messageId: string;
@@ -15,6 +21,12 @@ export interface ChannelLike {
15
21
  connect(): Promise<void>;
16
22
  disconnect(): Promise<void>;
17
23
  sendText(to: string, text: string, replyTo?: string): Promise<void>;
24
+ startCardStream(to: string, card: object, replyTo?: string): Promise<ChannelCardStream>;
25
+ }
26
+
27
+ export interface ChannelCardStream {
28
+ update(card: object): Promise<void>;
29
+ finish(): Promise<void>;
18
30
  }
19
31
 
20
32
  export type ChannelFactory = (credentials: FeishuCredentials) => ChannelLike;
@@ -69,6 +81,14 @@ export class SdkFeishuGateway implements FeishuGateway {
69
81
  if (!channel) throw new Error("飞书长连接尚未启动。");
70
82
  await channel.sendText(chatId, text, replyTo);
71
83
  }
84
+
85
+ async beginReply(chatId: string, replyTo: string): Promise<FeishuReply> {
86
+ const channel = this.channel;
87
+ if (!channel) throw new Error("飞书长连接尚未启动。");
88
+ const initial = { text: "", status: "正在思考" } satisfies FeishuReplySnapshot;
89
+ const stream = await channel.startCardStream(chatId, buildReplyCard(initial), replyTo);
90
+ return new SdkFeishuReply(stream, channel, chatId, replyTo, initial);
91
+ }
72
92
  }
73
93
 
74
94
  export async function validateSdkCredentials(
@@ -92,6 +112,10 @@ function createOfficialChannel(credentials: FeishuCredentials): ChannelLike {
92
112
  handshakeTimeoutMs: 10_000,
93
113
  loggerLevel: LoggerLevel.error,
94
114
  source: "pi-feishu",
115
+ outbound: {
116
+ streamThrottleMs: 500,
117
+ streamThrottleChars: 120,
118
+ },
95
119
  policy: {
96
120
  dmMode: "open",
97
121
  groupAllowlist: [],
@@ -126,6 +150,150 @@ class OfficialChannelAdapter implements ChannelLike {
126
150
  async sendText(to: string, text: string, replyTo?: string): Promise<void> {
127
151
  await this.channel.send(to, { text }, replyTo ? { replyTo } : undefined);
128
152
  }
153
+
154
+ async startCardStream(to: string, card: object, replyTo?: string): Promise<ChannelCardStream> {
155
+ const stream = new OfficialChannelCardStream(this.channel, to, card, replyTo);
156
+ await stream.start();
157
+ return stream;
158
+ }
159
+ }
160
+
161
+ interface CardStreamControllerLike {
162
+ update(card: object): Promise<void>;
163
+ }
164
+
165
+ class OfficialChannelCardStream implements ChannelCardStream {
166
+ private controller: CardStreamControllerLike | undefined;
167
+ private resolveOpened: (() => void) | undefined;
168
+ private resolveProducer: (() => void) | undefined;
169
+ private readonly opened: Promise<void>;
170
+ private readonly producerCompleted: Promise<void>;
171
+ private readonly stream: Promise<void>;
172
+
173
+ constructor(channel: LarkChannel, to: string, card: object, replyTo?: string) {
174
+ this.opened = new Promise<void>((resolve) => {
175
+ this.resolveOpened = resolve;
176
+ });
177
+ this.producerCompleted = new Promise<void>((resolve) => {
178
+ this.resolveProducer = resolve;
179
+ });
180
+ this.stream = channel
181
+ .stream(
182
+ to,
183
+ {
184
+ card: {
185
+ initial: card,
186
+ producer: async (controller) => {
187
+ this.controller = controller;
188
+ this.resolveOpened?.();
189
+ await this.producerCompleted;
190
+ },
191
+ },
192
+ },
193
+ replyTo ? { replyTo } : undefined,
194
+ )
195
+ .then(() => undefined);
196
+ }
197
+
198
+ async start(): Promise<void> {
199
+ await Promise.race([this.opened, this.stream]);
200
+ }
201
+
202
+ async update(card: object): Promise<void> {
203
+ const controller = this.controller;
204
+ if (!controller) throw new Error("飞书流式回复尚未准备完成。");
205
+ await controller.update(card);
206
+ }
207
+
208
+ async finish(): Promise<void> {
209
+ this.resolveProducer?.();
210
+ await this.stream;
211
+ }
212
+ }
213
+
214
+ class SdkFeishuReply implements FeishuReply {
215
+ private snapshot: FeishuReplySnapshot;
216
+ private terminal = false;
217
+ private updateFailed = false;
218
+
219
+ constructor(
220
+ private readonly stream: ChannelCardStream,
221
+ private readonly channel: ChannelLike,
222
+ private readonly chatId: string,
223
+ private readonly replyTo: string,
224
+ initial: FeishuReplySnapshot,
225
+ ) {
226
+ this.snapshot = initial;
227
+ }
228
+
229
+ update(snapshot: FeishuReplySnapshot): void {
230
+ if (this.terminal) return;
231
+ this.snapshot = snapshot;
232
+ void this.stream.update(buildReplyCard(snapshot)).catch(() => {
233
+ this.updateFailed = true;
234
+ });
235
+ }
236
+
237
+ async complete(snapshot: FeishuReplySnapshot): Promise<void> {
238
+ await this.finish(snapshot, snapshot.text || "Pi 已完成处理,但没有返回文本内容。");
239
+ }
240
+
241
+ async fail(): Promise<void> {
242
+ await this.finish(
243
+ { text: this.snapshot.text || "处理消息失败,请稍后再试。", status: "处理失败" },
244
+ "处理消息失败,请稍后再试。",
245
+ );
246
+ }
247
+
248
+ async cancel(): Promise<void> {
249
+ await this.finish({ text: this.snapshot.text || "任务已取消。", status: "已取消" }, "任务已取消。");
250
+ }
251
+
252
+ private async finish(snapshot: FeishuReplySnapshot, fallbackText: string): Promise<void> {
253
+ if (this.terminal) return;
254
+ this.terminal = true;
255
+ this.snapshot = snapshot;
256
+ try {
257
+ if (!this.updateFailed) await this.stream.update(buildReplyCard(snapshot));
258
+ await this.stream.finish();
259
+ } catch {
260
+ await this.channel.sendText(this.chatId, fallbackText, this.replyTo).catch(() => undefined);
261
+ }
262
+ }
263
+ }
264
+
265
+ export function buildReplyCard(snapshot: FeishuReplySnapshot): object {
266
+ const content = snapshot.text.trim() || statusPlaceholder(snapshot.status);
267
+ return {
268
+ config: { wide_screen_mode: true },
269
+ header: {
270
+ title: { tag: "plain_text", content: "Pi" },
271
+ template: headerTemplate(snapshot.status),
272
+ },
273
+ elements: [
274
+ { tag: "markdown", content },
275
+ {
276
+ tag: "note",
277
+ elements: [{ tag: "plain_text", content: snapshot.status }],
278
+ },
279
+ ],
280
+ };
281
+ }
282
+
283
+ function statusPlaceholder(status: FeishuReplySnapshot["status"]): string {
284
+ if (status === "正在执行工具") return "正在执行工具…";
285
+ if (status === "正在生成回复") return "正在生成回复…";
286
+ if (status === "已取消") return "任务已取消。";
287
+ if (status === "处理失败") return "处理消息失败,请稍后再试。";
288
+ if (status === "已完成") return "Pi 已完成处理,但没有返回文本内容。";
289
+ return "正在思考…";
290
+ }
291
+
292
+ function headerTemplate(status: FeishuReplySnapshot["status"]): "blue" | "green" | "red" | "grey" {
293
+ if (status === "已完成") return "green";
294
+ if (status === "处理失败") return "red";
295
+ if (status === "已取消") return "grey";
296
+ return "blue";
129
297
  }
130
298
 
131
299
  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 {