@hwj123weijian/pi-feishu 0.11.2 → 0.11.3
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 +4 -0
- package/package.json +1 -1
- package/src/controller.ts +73 -0
- package/src/extension.ts +27 -22
package/README.md
CHANGED
|
@@ -163,6 +163,10 @@ pi -e D:\ai_study\pi-feishu
|
|
|
163
163
|
|
|
164
164
|
已知限制:Pi 是单会话进程,群与群、群与私聊之间共享一个执行引擎,**跨聊天**的任务仍全局串行(排队提示会如实显示位数)。同一聊天内任务运行中继续发消息,会直接并入当前对话(steer),无需排队。远程命令作用于最近活跃的会话,`/model`、`/thinking`、`/compact` 建议在对应聊天的上一条消息之后紧接着发送。
|
|
165
165
|
|
|
166
|
+
### 与本地使用共存
|
|
167
|
+
|
|
168
|
+
Pi 同时是你本地的 TUI 工具:为避免飞书消息打断你正在终端里跑的任务,插件在驱动 Pi 前会检测本地是否繁忙——繁忙时飞书消息会等待(收到提示"本地 Pi 正在执行任务"),超过 15 分钟仍繁忙则取消处理并通知你重发。同理,群会话的目录锚定依赖会话文件落盘(Pi 在首条回复后才写盘),新会话的锚定从下一条消息开始生效。若在本地 Pi 手动切换过会话,远程路由的会话控制会过期一次:按提示在本地执行 `/feishu status` 刷新即可恢复。
|
|
169
|
+
|
|
166
170
|
未知命令会返回提示,不会发给 Pi。`/model` 的候选来自本地 Pi 的 scoped models(未配置时为全部已授权模型);`/model` 无参数时展示当前模型。注意:任何以 `/` 开头的消息都会先被当作命令解析,想发给 Pi 的提问请勿以 `/` 开头。
|
|
167
171
|
|
|
168
172
|
### 取消排队中的消息
|
package/package.json
CHANGED
package/src/controller.ts
CHANGED
|
@@ -90,6 +90,20 @@ export interface FeishuControllerOptions {
|
|
|
90
90
|
generateBindingCode?: () => string;
|
|
91
91
|
/** 落盘去重记录的路径;默认与凭据同目录,测试注入临时路径。 */
|
|
92
92
|
deduplicationPath?: string;
|
|
93
|
+
/** 本地忙闲轮询间隔(测试可调小)。 */
|
|
94
|
+
idlePollMs?: number;
|
|
95
|
+
/** 本地 Pi 持续繁忙多久后放弃处理(默认 15 分钟)。 */
|
|
96
|
+
localBusyTimeoutMs?: number;
|
|
97
|
+
/** 繁忙持续多久后给聊天发等待提示(默认 3 秒)。 */
|
|
98
|
+
busyNotifyAfterMs?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const DEFAULT_IDLE_POLL_MS = 2000;
|
|
102
|
+
const DEFAULT_LOCAL_BUSY_TIMEOUT_MS = 15 * 60 * 1000;
|
|
103
|
+
const DEFAULT_BUSY_NOTIFY_AFTER_MS = 3000;
|
|
104
|
+
|
|
105
|
+
function sleep(ms: number): Promise<void> {
|
|
106
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
93
107
|
}
|
|
94
108
|
|
|
95
109
|
export interface FeishuStartResult {
|
|
@@ -106,6 +120,11 @@ export class FeishuController {
|
|
|
106
120
|
private readonly generateBindingCode: (() => string) | undefined;
|
|
107
121
|
private readonly queue = new SerialMessageQueue();
|
|
108
122
|
private readonly deduplicator: MessageDeduplicator;
|
|
123
|
+
private readonly idlePollMs: number;
|
|
124
|
+
private readonly localBusyTimeoutMs: number;
|
|
125
|
+
private readonly busyNotifyAfterMs: number;
|
|
126
|
+
/** stop() 置位:让本地忙闲等待循环立即退出。 */
|
|
127
|
+
private stopped = false;
|
|
109
128
|
private gateway: FeishuGateway | undefined;
|
|
110
129
|
private credentials: FeishuCredentials | undefined;
|
|
111
130
|
private binding: OwnerBinding | undefined;
|
|
@@ -122,6 +141,9 @@ export class FeishuController {
|
|
|
122
141
|
this.agent = options.agent;
|
|
123
142
|
this.runtime = options.runtime;
|
|
124
143
|
this.generateBindingCode = options.generateBindingCode;
|
|
144
|
+
this.idlePollMs = options.idlePollMs ?? DEFAULT_IDLE_POLL_MS;
|
|
145
|
+
this.localBusyTimeoutMs = options.localBusyTimeoutMs ?? DEFAULT_LOCAL_BUSY_TIMEOUT_MS;
|
|
146
|
+
this.busyNotifyAfterMs = options.busyNotifyAfterMs ?? DEFAULT_BUSY_NOTIFY_AFTER_MS;
|
|
125
147
|
// 对齐 easycodeclient:受理即落盘的去重记录,进程重启后飞书重推也不会重复执行。
|
|
126
148
|
this.deduplicator = new MessageDeduplicator(5000, options.deduplicationPath ?? defaultProcessedMessagesPath());
|
|
127
149
|
}
|
|
@@ -170,6 +192,7 @@ export class FeishuController {
|
|
|
170
192
|
this.binding = binding;
|
|
171
193
|
this.gateway = gateway;
|
|
172
194
|
this.environment = environment;
|
|
195
|
+
this.stopped = false;
|
|
173
196
|
// 恢复落盘的受理记录:重启后飞书重推的事件仍会被去重,而不是重复驱动 Pi。
|
|
174
197
|
await this.deduplicator.hydrate();
|
|
175
198
|
|
|
@@ -250,6 +273,13 @@ export class FeishuController {
|
|
|
250
273
|
return Object.values(sessions).includes(sessionFile);
|
|
251
274
|
}
|
|
252
275
|
|
|
276
|
+
/** 向指定聊天发一条提示文字(fire-and-forget,失败静默)。 */
|
|
277
|
+
async notifyChat(chatId: string, text: string): Promise<void> {
|
|
278
|
+
const gateway = this.gateway;
|
|
279
|
+
if (!gateway) return;
|
|
280
|
+
await gateway.sendText(chatId, text).catch(() => undefined);
|
|
281
|
+
}
|
|
282
|
+
|
|
253
283
|
async setChatSessionFile(chatId: string, sessionFile: string): Promise<void> {
|
|
254
284
|
const credentials = this.credentials;
|
|
255
285
|
if (!credentials) return;
|
|
@@ -286,6 +316,7 @@ export class FeishuController {
|
|
|
286
316
|
async stop(): Promise<boolean> {
|
|
287
317
|
const gateway = this.gateway;
|
|
288
318
|
if (!gateway) return false;
|
|
319
|
+
this.stopped = true;
|
|
289
320
|
this.botAddedUnsubscribe?.();
|
|
290
321
|
this.botAddedUnsubscribe = undefined;
|
|
291
322
|
for (const entry of this.pendingTasks.values()) entry.cancelled = true;
|
|
@@ -835,8 +866,50 @@ export class FeishuController {
|
|
|
835
866
|
this.credentials = next;
|
|
836
867
|
}
|
|
837
868
|
|
|
869
|
+
/**
|
|
870
|
+
* 等待本地 Pi 空闲。超过 localBusyTimeoutMs 仍繁忙则放弃(返回 false),
|
|
871
|
+
* 等待超过 3 秒时先给聊天发一条提示,让用户知道消息没有丢。
|
|
872
|
+
*/
|
|
873
|
+
private async waitForLocalIdle(gateway: FeishuGateway, message: FeishuIncomingMessage): Promise<boolean> {
|
|
874
|
+
const runtime = this.runtime;
|
|
875
|
+
if (!runtime) return true;
|
|
876
|
+
const start = Date.now();
|
|
877
|
+
let notified = false;
|
|
878
|
+
for (;;) {
|
|
879
|
+
if (this.stopped || this.gateway !== gateway) return false;
|
|
880
|
+
let idle: boolean;
|
|
881
|
+
try {
|
|
882
|
+
idle = runtime.isIdle();
|
|
883
|
+
} catch {
|
|
884
|
+
idle = true; // 忙闲未知(ctx 失效等)时不得阻塞消息处理。
|
|
885
|
+
}
|
|
886
|
+
if (idle) return true;
|
|
887
|
+
const waitedMs = Date.now() - start;
|
|
888
|
+
if (waitedMs > this.localBusyTimeoutMs) return false;
|
|
889
|
+
if (!notified && waitedMs > this.busyNotifyAfterMs) {
|
|
890
|
+
notified = true;
|
|
891
|
+
await gateway
|
|
892
|
+
.sendText(
|
|
893
|
+
message.chatId,
|
|
894
|
+
"⏳ 本地 Pi 正在执行任务,本条消息将在其完成后处理(不会打断本地任务)。",
|
|
895
|
+
message.messageId,
|
|
896
|
+
)
|
|
897
|
+
.catch(() => undefined);
|
|
898
|
+
}
|
|
899
|
+
await sleep(this.idlePollMs);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
838
903
|
private async processWithAgent(gateway: FeishuGateway, message: FeishuIncomingMessage, text: string): Promise<void> {
|
|
839
904
|
if (this.gateway !== gateway) return;
|
|
905
|
+
// Pi 是单会话进程:本地 TUI 正在跑的任务会被会话切换 abort 掉。
|
|
906
|
+
// 因此驱动 Pi 之前先等本地空闲,绝不打断用户手头的工作。
|
|
907
|
+
if (!(await this.waitForLocalIdle(gateway, message))) {
|
|
908
|
+
await gateway
|
|
909
|
+
.sendText(message.chatId, "⏳ 本地 Pi 长时间繁忙,本条消息已取消处理,请稍后重发。", message.messageId)
|
|
910
|
+
.catch(() => undefined);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
840
913
|
const reply = await gateway.beginReply(message.chatId, message.messageId).catch(() => undefined);
|
|
841
914
|
if (reply) this.activeReplies.add(reply);
|
|
842
915
|
let latestText = "";
|
package/src/extension.ts
CHANGED
|
@@ -241,16 +241,24 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
|
|
|
241
241
|
if (sessionFile) {
|
|
242
242
|
// Pi 的 TUI switchSession 不透传 cwdOverride,改为把 cwd 写进会话头:
|
|
243
243
|
// SessionManager.open 切换时从 header 读取 cwd,runtime 随之在新目录重建。
|
|
244
|
-
|
|
245
|
-
|
|
244
|
+
// 会话文件可能尚未落盘(Pi 要等首条 assistant 回复才写文件)——此时跳过
|
|
245
|
+
// 锚定正常处理,本轮回复落盘后下一轮自动生效,绝不因锚定失败终止消息。
|
|
246
|
+
let anchored = true;
|
|
247
|
+
if (directory) {
|
|
248
|
+
anchored = await anchorSessionHeaderCwd(sessionFile, directory);
|
|
246
249
|
}
|
|
247
250
|
const result = await context.switchSession(sessionFile, {
|
|
248
251
|
withSession: (nextContext) => {
|
|
249
|
-
if (directory)
|
|
252
|
+
if (directory && anchored) checkAnchoredCwd(state, chatId, nextContext, directory);
|
|
250
253
|
return sendAndTrackGroupSession(state, chatId, sessionFile, nextContext, prompt);
|
|
251
254
|
},
|
|
252
255
|
});
|
|
253
256
|
if (result.cancelled) throw new Error("切换到飞书群绑定的 Pi 会话已取消。");
|
|
257
|
+
if (directory && !anchored) {
|
|
258
|
+
await state.controller
|
|
259
|
+
.notifyChat(chatId, "ℹ️ 本群的目录锚定将在下一条消息生效(当前会话文件尚未写入磁盘)。")
|
|
260
|
+
.catch(() => undefined);
|
|
261
|
+
}
|
|
254
262
|
return;
|
|
255
263
|
}
|
|
256
264
|
|
|
@@ -259,21 +267,12 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
|
|
|
259
267
|
state.latestCommandContext = nextContext;
|
|
260
268
|
const currentFile = nextContext.sessionManager.getSessionFile();
|
|
261
269
|
if (currentFile) await state.controller.setChatSessionFile(chatId, currentFile);
|
|
262
|
-
if (
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const anchored = await nextContext.switchSession(currentFile, {
|
|
269
|
-
withSession: (finalContext) => {
|
|
270
|
-
state.latestCommandContext = finalContext;
|
|
271
|
-
assertAnchoredCwd(finalContext, directory);
|
|
272
|
-
return finalContext.sendUserMessage(prompt);
|
|
273
|
-
},
|
|
274
|
-
});
|
|
275
|
-
if (anchored.cancelled) throw new Error("锚定群会话工作目录已取消。");
|
|
276
|
-
return;
|
|
270
|
+
if (directory) {
|
|
271
|
+
// 全新会话的文件还没落盘(Pi 等首条 assistant 回复才写):本轮先在
|
|
272
|
+
// 继承的目录下处理,回复落盘后下一轮消息到来时锚定自动生效。
|
|
273
|
+
await state.controller
|
|
274
|
+
.notifyChat(chatId, `ℹ️ 本群已锚定到目录:${directory},将从下一条消息开始在该目录下工作。`)
|
|
275
|
+
.catch(() => undefined);
|
|
277
276
|
}
|
|
278
277
|
await nextContext.sendUserMessage(prompt);
|
|
279
278
|
},
|
|
@@ -320,12 +319,18 @@ export async function anchorSessionHeaderCwd(sessionFile: string, directory: str
|
|
|
320
319
|
return true;
|
|
321
320
|
}
|
|
322
321
|
|
|
323
|
-
|
|
322
|
+
/** 锚定后校验 runtime cwd 是否真的切了过去;不符只提示,不终止消息。 */
|
|
323
|
+
function checkAnchoredCwd(
|
|
324
|
+
state: SharedFeishuState,
|
|
325
|
+
chatId: string,
|
|
326
|
+
context: ExtensionContext,
|
|
327
|
+
directory: string,
|
|
328
|
+
): void {
|
|
324
329
|
const effective = safeCwd(context);
|
|
325
330
|
if (effective && resolvePath(effective) !== resolvePath(directory)) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
331
|
+
void state.controller
|
|
332
|
+
.notifyChat(chatId, `⚠️ 群会话未能切换到绑定目录(当前:${effective},期望:${directory})。`)
|
|
333
|
+
.catch(() => undefined);
|
|
329
334
|
}
|
|
330
335
|
}
|
|
331
336
|
|