@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 +13 -4
- package/package.json +1 -1
- package/src/contracts.ts +21 -1
- package/src/controller.ts +31 -3
- package/src/extension.ts +9 -0
- package/src/gateway.ts +169 -1
- package/src/pi-agent-bridge.ts +27 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-feishu
|
|
2
2
|
|
|
3
|
-
|
|
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
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
package/src/pi-agent-bridge.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|