@hwj123weijian/pi-feishu 0.11.1 → 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 +66 -27
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
|
@@ -100,8 +100,13 @@ function getSharedState(): SharedFeishuState {
|
|
|
100
100
|
steer: (text) => {
|
|
101
101
|
// 仅当确有飞书轮次在跑时并入:发到当前活跃会话(轮次就在那里),绝不触发会话切换。
|
|
102
102
|
if (!state.activeBridge || !state.sendCurrentMessage) return false;
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
try {
|
|
104
|
+
state.sendCurrentMessage(text, { deliverAs: "steer" });
|
|
105
|
+
return true;
|
|
106
|
+
} catch {
|
|
107
|
+
// pi 已失效等场景:并入失败,回落到正常排队。
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
105
110
|
},
|
|
106
111
|
};
|
|
107
112
|
const proxyRuntime: PiRuntime = {
|
|
@@ -122,6 +127,8 @@ function getSharedState(): SharedFeishuState {
|
|
|
122
127
|
},
|
|
123
128
|
});
|
|
124
129
|
return !result.cancelled;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
rethrowStaleAsHint(state, error);
|
|
125
132
|
} finally {
|
|
126
133
|
state.switchingSession = false;
|
|
127
134
|
}
|
|
@@ -165,6 +172,24 @@ function tryGetSessionFile(context: ExtensionContext): string | undefined {
|
|
|
165
172
|
}
|
|
166
173
|
}
|
|
167
174
|
|
|
175
|
+
/** 缓存的命令上下文已失效(Pi 侧发生过会话替换)时的可操作提示。 */
|
|
176
|
+
export const SESSION_CONTROL_HINT =
|
|
177
|
+
"本地 Pi 会话控制已过期(本地发生过会话切换)。请在本地 Pi 执行一次 /feishu status 刷新,然后重发这条消息。";
|
|
178
|
+
|
|
179
|
+
function isStaleCtxError(error: unknown): boolean {
|
|
180
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
181
|
+
return message.includes("extension ctx is stale") || message.includes("ctx is stale after session replacement");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 把 Pi 的英文 stale 内部错误转译成可操作指引,其余错误原样上抛。 */
|
|
185
|
+
function rethrowStaleAsHint(state: SharedFeishuState, error: unknown): never {
|
|
186
|
+
if (isStaleCtxError(error)) {
|
|
187
|
+
state.latestCommandContext = undefined;
|
|
188
|
+
throw new Error(SESSION_CONTROL_HINT);
|
|
189
|
+
}
|
|
190
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
191
|
+
}
|
|
192
|
+
|
|
168
193
|
/**
|
|
169
194
|
* p2p 私聊消息永远落在主会话:群消息处理会把 Pi 切进群的会话,
|
|
170
195
|
* 这里负责在下一条私聊到来时切回去,避免把 Owner 的提问发进群会话。
|
|
@@ -182,7 +207,7 @@ async function sendToMainSession(state: SharedFeishuState, prompt: string): Prom
|
|
|
182
207
|
const context = state.latestCommandContext;
|
|
183
208
|
if (!context) {
|
|
184
209
|
// 主会话与当前会话不一致却没有命令上下文:直发会串进群会话,给出可操作的错误更安全。
|
|
185
|
-
throw new Error(
|
|
210
|
+
throw new Error(SESSION_CONTROL_HINT);
|
|
186
211
|
}
|
|
187
212
|
state.switchingSession = true;
|
|
188
213
|
try {
|
|
@@ -193,32 +218,47 @@ async function sendToMainSession(state: SharedFeishuState, prompt: string): Prom
|
|
|
193
218
|
},
|
|
194
219
|
});
|
|
195
220
|
if (result.cancelled) throw new Error("切回主 Pi 会话已取消。");
|
|
221
|
+
} catch (error) {
|
|
222
|
+
rethrowStaleAsHint(state, error);
|
|
196
223
|
} finally {
|
|
197
224
|
state.switchingSession = false;
|
|
198
225
|
}
|
|
199
226
|
}
|
|
200
227
|
|
|
201
228
|
async function sendToChatSession(state: SharedFeishuState, chatId: string, prompt: string): Promise<void> {
|
|
229
|
+
const sessionFile = state.controller.getChatSessionFile(chatId);
|
|
230
|
+
// Pi 侧切换后当前会话可能恰好就是目标群会话:此时无需命令上下文,直接投递。
|
|
231
|
+
if (sessionFile && sessionFile === state.currentSessionFile && state.sendCurrentMessage) {
|
|
232
|
+
state.sendCurrentMessage(prompt);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
202
235
|
const context = state.latestCommandContext;
|
|
203
|
-
if (!context) throw new Error(
|
|
236
|
+
if (!context) throw new Error(SESSION_CONTROL_HINT);
|
|
204
237
|
|
|
205
238
|
const directory = state.controller.getChatDirectory(chatId);
|
|
206
239
|
state.switchingSession = true;
|
|
207
240
|
try {
|
|
208
|
-
const sessionFile = state.controller.getChatSessionFile(chatId);
|
|
209
241
|
if (sessionFile) {
|
|
210
242
|
// Pi 的 TUI switchSession 不透传 cwdOverride,改为把 cwd 写进会话头:
|
|
211
243
|
// SessionManager.open 切换时从 header 读取 cwd,runtime 随之在新目录重建。
|
|
212
|
-
|
|
213
|
-
|
|
244
|
+
// 会话文件可能尚未落盘(Pi 要等首条 assistant 回复才写文件)——此时跳过
|
|
245
|
+
// 锚定正常处理,本轮回复落盘后下一轮自动生效,绝不因锚定失败终止消息。
|
|
246
|
+
let anchored = true;
|
|
247
|
+
if (directory) {
|
|
248
|
+
anchored = await anchorSessionHeaderCwd(sessionFile, directory);
|
|
214
249
|
}
|
|
215
250
|
const result = await context.switchSession(sessionFile, {
|
|
216
251
|
withSession: (nextContext) => {
|
|
217
|
-
if (directory)
|
|
252
|
+
if (directory && anchored) checkAnchoredCwd(state, chatId, nextContext, directory);
|
|
218
253
|
return sendAndTrackGroupSession(state, chatId, sessionFile, nextContext, prompt);
|
|
219
254
|
},
|
|
220
255
|
});
|
|
221
256
|
if (result.cancelled) throw new Error("切换到飞书群绑定的 Pi 会话已取消。");
|
|
257
|
+
if (directory && !anchored) {
|
|
258
|
+
await state.controller
|
|
259
|
+
.notifyChat(chatId, "ℹ️ 本群的目录锚定将在下一条消息生效(当前会话文件尚未写入磁盘)。")
|
|
260
|
+
.catch(() => undefined);
|
|
261
|
+
}
|
|
222
262
|
return;
|
|
223
263
|
}
|
|
224
264
|
|
|
@@ -227,26 +267,19 @@ async function sendToChatSession(state: SharedFeishuState, chatId: string, promp
|
|
|
227
267
|
state.latestCommandContext = nextContext;
|
|
228
268
|
const currentFile = nextContext.sessionManager.getSessionFile();
|
|
229
269
|
if (currentFile) await state.controller.setChatSessionFile(chatId, currentFile);
|
|
230
|
-
if (
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const anchored = await nextContext.switchSession(currentFile, {
|
|
237
|
-
withSession: (finalContext) => {
|
|
238
|
-
state.latestCommandContext = finalContext;
|
|
239
|
-
assertAnchoredCwd(finalContext, directory);
|
|
240
|
-
return finalContext.sendUserMessage(prompt);
|
|
241
|
-
},
|
|
242
|
-
});
|
|
243
|
-
if (anchored.cancelled) throw new Error("锚定群会话工作目录已取消。");
|
|
244
|
-
return;
|
|
270
|
+
if (directory) {
|
|
271
|
+
// 全新会话的文件还没落盘(Pi 等首条 assistant 回复才写):本轮先在
|
|
272
|
+
// 继承的目录下处理,回复落盘后下一轮消息到来时锚定自动生效。
|
|
273
|
+
await state.controller
|
|
274
|
+
.notifyChat(chatId, `ℹ️ 本群已锚定到目录:${directory},将从下一条消息开始在该目录下工作。`)
|
|
275
|
+
.catch(() => undefined);
|
|
245
276
|
}
|
|
246
277
|
await nextContext.sendUserMessage(prompt);
|
|
247
278
|
},
|
|
248
279
|
});
|
|
249
280
|
if (result.cancelled) throw new Error("创建飞书群专属 Pi 会话已取消。");
|
|
281
|
+
} catch (error) {
|
|
282
|
+
rethrowStaleAsHint(state, error);
|
|
250
283
|
} finally {
|
|
251
284
|
state.switchingSession = false;
|
|
252
285
|
}
|
|
@@ -286,12 +319,18 @@ export async function anchorSessionHeaderCwd(sessionFile: string, directory: str
|
|
|
286
319
|
return true;
|
|
287
320
|
}
|
|
288
321
|
|
|
289
|
-
|
|
322
|
+
/** 锚定后校验 runtime cwd 是否真的切了过去;不符只提示,不终止消息。 */
|
|
323
|
+
function checkAnchoredCwd(
|
|
324
|
+
state: SharedFeishuState,
|
|
325
|
+
chatId: string,
|
|
326
|
+
context: ExtensionContext,
|
|
327
|
+
directory: string,
|
|
328
|
+
): void {
|
|
290
329
|
const effective = safeCwd(context);
|
|
291
330
|
if (effective && resolvePath(effective) !== resolvePath(directory)) {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
331
|
+
void state.controller
|
|
332
|
+
.notifyChat(chatId, `⚠️ 群会话未能切换到绑定目录(当前:${effective},期望:${directory})。`)
|
|
333
|
+
.catch(() => undefined);
|
|
295
334
|
}
|
|
296
335
|
}
|
|
297
336
|
|