cursor-feedback 2.1.1 → 2.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/CHANGELOG.md +14 -0
- package/README.md +1 -0
- package/README_CN.md +1 -0
- package/dist/extension.js +84 -34
- package/dist/feishu.js +19 -2
- package/dist/i18n/en.json +3 -0
- package/dist/i18n/index.js +1 -0
- package/dist/i18n/zh-CN.json +3 -0
- package/dist/mcp-server.js +459 -102
- package/dist/webview/index.html +8 -0
- package/dist/webview/script.js +15 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [2.2.0](https://github.com/jianger666/cursor-feedback-extension/compare/v2.1.2...v2.2.0) (2026-07-03)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **feishu:** 忙时消息排队——AI 干活期间的飞书消息不再丢失 ([fd8aa75](https://github.com/jianger666/cursor-feedback-extension/commit/fd8aa7540a787dbb44e85f35ea137a0c0ea9c03e))
|
|
11
|
+
|
|
12
|
+
### [2.1.2](https://github.com/jianger666/cursor-feedback-extension/compare/v2.1.1...v2.1.2) (2026-07-03)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **mcp:** 多窗口/多对话共用进程的反馈路由修复——按窗口查询 + 重复投递去重 + 暂停保护 ([3b3d451](https://github.com/jianger666/cursor-feedback-extension/commit/3b3d451453ee5a04ce29929845d22191116e0f96))
|
|
18
|
+
|
|
5
19
|
### [2.1.1](https://github.com/jianger666/cursor-feedback-extension/compare/v2.1.0...v2.1.1) (2026-07-02)
|
|
6
20
|
|
|
7
21
|
|
package/README.md
CHANGED
|
@@ -227,6 +227,7 @@ Either way works — but you first need to set up the bot in the Feishu console:
|
|
|
227
227
|
| `FEISHU_APP_SECRET` | - | Feishu app App Secret |
|
|
228
228
|
| `FEISHU_ENABLED` | `true` | Whether to push feedback to Feishu. Set to `false` to disable |
|
|
229
229
|
| `FEISHU_ACK` | `true` | Whether to react with a "Get" emoji after you reply. Set to `false` to disable |
|
|
230
|
+
| `FEISHU_QUEUE` | `true` | Queue messages while the AI is busy: messages sent when no feedback request is waiting are queued and auto-delivered on the AI's next feedback round, prefixed with an "appended during the task" hint; the bot acknowledges with a "queued" reply. Set to `false` to disable (also toggleable in the panel's notification settings) |
|
|
230
231
|
|
|
231
232
|
> Priority: **panel config (when credentials are filled) > env here > default**. The panel wins when App ID/Secret are filled; otherwise it falls back to env. You still need to send the bot one message in Feishu to complete binding.
|
|
232
233
|
|
package/README_CN.md
CHANGED
|
@@ -231,6 +231,7 @@ npm install -g cursor-feedback
|
|
|
231
231
|
| `FEISHU_APP_SECRET` | - | 飞书应用 App Secret |
|
|
232
232
|
| `FEISHU_ENABLED` | `true` | 是否推送反馈到飞书,设 `false` 关闭 |
|
|
233
233
|
| `FEISHU_ACK` | `true` | 收到你的回复后是否回「Get」表情回执,设 `false` 关闭 |
|
|
234
|
+
| `FEISHU_QUEUE` | `true` | 忙时消息排队:AI 正忙(没有等待中的反馈)时你发的消息先排队,等 AI 下一轮询问时自动送达并附「任务期间追加」提示;排队时机器人会回执「已排队,任务完成后自动读取」。设 `false` 关闭(也可在面板「通知设置」里切换) |
|
|
234
235
|
|
|
235
236
|
> 优先级:**插件面板(填了凭证)> 这里的 env > 默认**。面板里填了 App ID/Secret 就以面板为准;没填则回退到 env。首次仍需在飞书里给机器人发一条消息完成绑定。
|
|
236
237
|
|
package/dist/extension.js
CHANGED
|
@@ -161,7 +161,12 @@ class FeedbackViewProvider {
|
|
|
161
161
|
this._pendingInsert = null;
|
|
162
162
|
this._activePort = null;
|
|
163
163
|
this._portScanRange = 20; // 扫描端口范围
|
|
164
|
-
this._seenRequestIds = new Set(); //
|
|
164
|
+
this._seenRequestIds = new Set(); // 见过的请求 ID(只用于「新鲜提醒」去重,不再挡显示)
|
|
165
|
+
// 本窗口已提交 / 已被外部渠道结束的请求 ID:绝不复显。
|
|
166
|
+
// 与 _seenRequestIds 分开的原因:同窗口多对话并存时(如一个等待被暂停、另一对话又发起新等待),
|
|
167
|
+
// 被覆盖的旧请求在新请求结束后会重新从 server 返回,此时它虽「见过」但没提交过,必须能回到面板;
|
|
168
|
+
// 旧实现用 seen 一刀切挡显示,旧请求就永远回不来了。
|
|
169
|
+
this._resolvedRequestIds = new Set();
|
|
165
170
|
// 超时续期开关(由侧边栏按钮切换,随轮询同步给 MCP server)
|
|
166
171
|
this._autoRetry = true;
|
|
167
172
|
// 飞书配置(持久化在 globalState;secret 会回显给 webview,前端用小眼睛切换明文/掩码)
|
|
@@ -172,6 +177,8 @@ class FeedbackViewProvider {
|
|
|
172
177
|
this._feishuEnabled = true;
|
|
173
178
|
// Get 表情回执子开关(飞书通知子项;关掉后用户飞书回复不加 Get 表情、也不发文字兜底)
|
|
174
179
|
this._feishuAck = true;
|
|
180
|
+
// 忙时消息排队子开关(飞书通知子项;AI 正忙时用户消息入队,下一轮 feedback 自动送达)
|
|
181
|
+
this._feishuQueue = true;
|
|
175
182
|
this._debugInfo = {
|
|
176
183
|
portRange: '',
|
|
177
184
|
workspacePath: '',
|
|
@@ -189,6 +196,7 @@ class FeedbackViewProvider {
|
|
|
189
196
|
this._feishuConfig = { appId: fc.appId || '', appSecret: fc.appSecret || '' };
|
|
190
197
|
this._feishuEnabled = this._memento?.get('feishuEnabled', true) ?? true;
|
|
191
198
|
this._feishuAck = this._memento?.get('feishuAck', true) ?? true;
|
|
199
|
+
this._feishuQueue = this._memento?.get('feishuQueue', true) ?? true;
|
|
192
200
|
}
|
|
193
201
|
/**
|
|
194
202
|
* 获取翻译消息
|
|
@@ -272,6 +280,9 @@ class FeedbackViewProvider {
|
|
|
272
280
|
case 'toggleFeishuAck':
|
|
273
281
|
await this._handleToggleFeishuAck(!!data.payload?.enabled);
|
|
274
282
|
break;
|
|
283
|
+
case 'toggleFeishuQueue':
|
|
284
|
+
await this._handleToggleFeishuQueue(!!data.payload?.enabled);
|
|
285
|
+
break;
|
|
275
286
|
case 'testNotification':
|
|
276
287
|
this._sendTestNotification();
|
|
277
288
|
break;
|
|
@@ -336,15 +347,19 @@ class FeedbackViewProvider {
|
|
|
336
347
|
// 如果有活跃端口,先尝试只轮询该端口
|
|
337
348
|
if (this._activePort) {
|
|
338
349
|
const result = await this._checkPortForRequest(this._activePort);
|
|
339
|
-
// 检查是否仍然是我们的 Server
|
|
350
|
+
// 检查是否仍然是我们的 Server。注意:多窗口/多对话共用一个 MCP 进程时,
|
|
351
|
+
// server 的 ownerWorkspace(进程归属,单值)可能是别的窗口——但只要它按窗口
|
|
352
|
+
// 返回了本工作区的请求(request 已在 _checkPortForRequest 里按 projectDir 过滤),
|
|
353
|
+
// 这个端口对本窗口就是有效的,不能因 owner 不匹配而丢弃请求。
|
|
340
354
|
if (result.connected) {
|
|
341
355
|
const serverOwner = result.ownerWorkspace ? normalizePath(result.ownerWorkspace) : '';
|
|
342
|
-
const isMyServer = !serverOwner || pathsRelated(serverOwner, normalizedCurrentWorkspace);
|
|
356
|
+
const isMyServer = !!result.request || !serverOwner || pathsRelated(serverOwner, normalizedCurrentWorkspace);
|
|
343
357
|
if (isMyServer) {
|
|
344
358
|
// 端口仍然有效,保持使用
|
|
345
359
|
this._debugInfo.connectedPorts = [this._activePort];
|
|
346
360
|
this._debugInfo.activePort = this._activePort;
|
|
347
|
-
|
|
361
|
+
// 只挡「已提交/已结束」的请求;_handleNewRequest 内部按当前显示态去重,重复调用无害
|
|
362
|
+
if (result.request && !this._resolvedRequestIds.has(result.request.id)) {
|
|
348
363
|
this._debugInfo.lastStatus = this._t('statusListening', { port: this._activePort });
|
|
349
364
|
this._handleNewRequest(result.request, this._activePort);
|
|
350
365
|
this._updateDebugInfo();
|
|
@@ -375,14 +390,13 @@ class FeedbackViewProvider {
|
|
|
375
390
|
const results = await Promise.all(ports.map(port => this._checkPortForRequest(port)));
|
|
376
391
|
// 更新已连接的端口列表
|
|
377
392
|
this._debugInfo.connectedPorts = results.filter(r => r.connected).map(r => r.port);
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
}).sort((a, b) => b.request.timestamp - a.request.timestamp);
|
|
393
|
+
// 找出属于当前工作区的请求(只排除已提交/已结束的;「见过但没提交」的仍要能回到面板)。
|
|
394
|
+
// 不再用 server 进程级 ownerWorkspace 二次过滤:request 已在 _checkPortForRequest
|
|
395
|
+
// 按本窗口工作区过滤过(isPathInWorkspace);多窗口共用一个 MCP 进程时 owner 只反映
|
|
396
|
+
// 首个对话的窗口,按它过滤会把其他窗口的请求全部滤掉(面板不显示、只有飞书收到)。
|
|
397
|
+
const myRequests = results
|
|
398
|
+
.filter(r => !!r.request && !this._resolvedRequestIds.has(r.request.id))
|
|
399
|
+
.sort((a, b) => b.request.timestamp - a.request.timestamp);
|
|
386
400
|
// 处理最新的请求
|
|
387
401
|
if (myRequests.length > 0) {
|
|
388
402
|
const newest = myRequests[0];
|
|
@@ -471,41 +485,50 @@ class FeedbackViewProvider {
|
|
|
471
485
|
}
|
|
472
486
|
}
|
|
473
487
|
/**
|
|
474
|
-
*
|
|
488
|
+
* 处理新的反馈请求。
|
|
489
|
+
* 幂等:同一请求正在显示时重复调用直接跳过;已提交/已结束的绝不复显;
|
|
490
|
+
* 「见过但没提交」的请求(同窗口多对话时被新请求覆盖过)允许重新显示,只是不再重复提醒。
|
|
475
491
|
*/
|
|
476
492
|
_handleNewRequest(request, port) {
|
|
477
|
-
//
|
|
478
|
-
if (this.
|
|
493
|
+
// 已提交 / 已被外部渠道结束 → 绝不复显(防旧版 server 的 currentRequest 清理滞后导致复弹)
|
|
494
|
+
if (this._resolvedRequestIds.has(request.id)) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
// 正在显示的就是它 → 无事可做
|
|
498
|
+
if (this._currentRequest && request.id === this._currentRequest.id) {
|
|
479
499
|
return;
|
|
480
500
|
}
|
|
481
501
|
// 判断是否为"新鲜"请求:创建后 10 秒内被发现
|
|
482
502
|
const requestAge = Date.now() - request.timestamp;
|
|
483
503
|
const isFreshRequest = requestAge < 10000; // 10秒内
|
|
484
|
-
//
|
|
504
|
+
// seen 只用于「主动提醒」去重:同一请求只 focus / 系统通知一次,重新回到面板时安静显示
|
|
505
|
+
const alreadySeen = this._seenRequestIds.has(request.id);
|
|
485
506
|
this._seenRequestIds.add(request.id);
|
|
486
507
|
// 清理旧的请求 ID(保留最近 100 个)
|
|
487
508
|
if (this._seenRequestIds.size > 100) {
|
|
488
509
|
const ids = Array.from(this._seenRequestIds);
|
|
489
510
|
this._seenRequestIds = new Set(ids.slice(-50));
|
|
490
511
|
}
|
|
491
|
-
if (
|
|
492
|
-
|
|
493
|
-
this.
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
512
|
+
if (this._resolvedRequestIds.size > 100) {
|
|
513
|
+
const ids = Array.from(this._resolvedRequestIds);
|
|
514
|
+
this._resolvedRequestIds = new Set(ids.slice(-50));
|
|
515
|
+
}
|
|
516
|
+
this._currentRequest = request;
|
|
517
|
+
this._activePort = port;
|
|
518
|
+
this._currentRequestPort = port;
|
|
519
|
+
// 「插件通知」主开关(配置 key 历史原因仍叫 systemNotification):关掉后本窗口完全静默——
|
|
520
|
+
// 不推送内容、不弹面板、不抢焦点、不发系统通知;连用户之后主动切回 / 打开面板也不显示
|
|
521
|
+
// (见 resolveWebviewView 里 ready 与 onDidChangeVisibility 的同款判断)。
|
|
522
|
+
// 请求仍记录在 _currentRequest,仅用于去重与外部渠道(飞书 / 超时)resolve 关联。
|
|
523
|
+
if (!this._isPluginNotifyEnabled()) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
// 推送内容并显示面板
|
|
527
|
+
this._showFeedbackRequest(request);
|
|
528
|
+
// 只对「新鲜且首次见到」的请求做主动提醒(聚焦面板 + IDE 提示 + 失焦系统通知)
|
|
529
|
+
if (isFreshRequest && !alreadySeen) {
|
|
530
|
+
vscode.commands.executeCommand('cursorFeedback.feedbackView.focus');
|
|
531
|
+
this._sendSystemNotification(request);
|
|
509
532
|
}
|
|
510
533
|
}
|
|
511
534
|
/**
|
|
@@ -687,6 +710,7 @@ class FeedbackViewProvider {
|
|
|
687
710
|
if (!this._currentRequest)
|
|
688
711
|
return;
|
|
689
712
|
this._seenRequestIds.add(this._currentRequest.id);
|
|
713
|
+
this._resolvedRequestIds.add(this._currentRequest.id);
|
|
690
714
|
this._currentRequest = null;
|
|
691
715
|
this._currentRequestPort = null;
|
|
692
716
|
this._showWaitingState();
|
|
@@ -722,6 +746,8 @@ class FeedbackViewProvider {
|
|
|
722
746
|
}));
|
|
723
747
|
const result = JSON.parse(response);
|
|
724
748
|
if (result.success) {
|
|
749
|
+
// 记入「已提交」:该请求此后绝不复显(server 端清理有滞后,轮询可能还会拿到它)
|
|
750
|
+
this._resolvedRequestIds.add(payload.requestId);
|
|
725
751
|
this._currentRequest = null;
|
|
726
752
|
this._currentRequestPort = null;
|
|
727
753
|
this._showWaitingState();
|
|
@@ -907,6 +933,14 @@ class FeedbackViewProvider {
|
|
|
907
933
|
payload: { requestId, paused: result.paused, remainingMs: result.remainingMs }
|
|
908
934
|
});
|
|
909
935
|
}
|
|
936
|
+
else {
|
|
937
|
+
// 请求已在 server 端结束(超时/被回复)→ 明确提示,不能静默:
|
|
938
|
+
// 用户以为暂停成功离开,实际倒计时早没了,回来发现等待消失会一头雾水
|
|
939
|
+
this._view?.webview.postMessage({
|
|
940
|
+
type: 'toast',
|
|
941
|
+
payload: { text: this._i18n.pauseFailedEnded }
|
|
942
|
+
});
|
|
943
|
+
}
|
|
910
944
|
}
|
|
911
945
|
catch {
|
|
912
946
|
// server 不支持(旧版本)或未连接:静默降级,倒计时照常走
|
|
@@ -945,6 +979,7 @@ class FeedbackViewProvider {
|
|
|
945
979
|
bound: this._feishuBound,
|
|
946
980
|
feishuEnabled: this._feishuEnabled,
|
|
947
981
|
feishuAck: this._feishuAck,
|
|
982
|
+
feishuQueue: this._feishuQueue,
|
|
948
983
|
systemNotification: !!systemNotification,
|
|
949
984
|
osNotification: !!osNotification
|
|
950
985
|
}
|
|
@@ -979,6 +1014,7 @@ class FeedbackViewProvider {
|
|
|
979
1014
|
const appSecret = feishuStatus.appSecret || '';
|
|
980
1015
|
const enabled = feishuStatus.enabled !== false;
|
|
981
1016
|
const ack = feishuStatus.ackReaction !== false;
|
|
1017
|
+
const queue = feishuStatus.queueWhenBusy !== false;
|
|
982
1018
|
const bound = !!feishuStatus.boundChatId;
|
|
983
1019
|
let changed = false;
|
|
984
1020
|
if (appId !== this._feishuConfig.appId || appSecret !== this._feishuConfig.appSecret) {
|
|
@@ -995,6 +1031,10 @@ class FeedbackViewProvider {
|
|
|
995
1031
|
this._feishuAck = ack;
|
|
996
1032
|
changed = true;
|
|
997
1033
|
}
|
|
1034
|
+
if (queue !== this._feishuQueue) {
|
|
1035
|
+
this._feishuQueue = queue;
|
|
1036
|
+
changed = true;
|
|
1037
|
+
}
|
|
998
1038
|
if (bound !== this._feishuBound) {
|
|
999
1039
|
this._feishuBound = bound;
|
|
1000
1040
|
changed = true;
|
|
@@ -1013,6 +1053,7 @@ class FeedbackViewProvider {
|
|
|
1013
1053
|
appSecret,
|
|
1014
1054
|
enabled: this._feishuEnabled,
|
|
1015
1055
|
ackReaction: this._feishuAck,
|
|
1056
|
+
queueWhenBusy: this._feishuQueue,
|
|
1016
1057
|
});
|
|
1017
1058
|
for (const port of ports) {
|
|
1018
1059
|
this._httpPost(`http://127.0.0.1:${port}/api/feishu/config`, body).catch(() => { });
|
|
@@ -1072,6 +1113,15 @@ class FeedbackViewProvider {
|
|
|
1072
1113
|
this._broadcastFeishuConfig();
|
|
1073
1114
|
this._postFeishuState();
|
|
1074
1115
|
}
|
|
1116
|
+
/**
|
|
1117
|
+
* 切换「忙时消息排队」子开关(飞书通知的子项;透传到 server 端,控制忙时队列)
|
|
1118
|
+
*/
|
|
1119
|
+
async _handleToggleFeishuQueue(enabled) {
|
|
1120
|
+
this._feishuQueue = enabled;
|
|
1121
|
+
await this._memento?.update('feishuQueue', enabled);
|
|
1122
|
+
this._broadcastFeishuConfig();
|
|
1123
|
+
this._postFeishuState();
|
|
1124
|
+
}
|
|
1075
1125
|
/**
|
|
1076
1126
|
* HTTP GET 请求
|
|
1077
1127
|
*/
|
package/dist/feishu.js
CHANGED
|
@@ -63,6 +63,8 @@ class FeishuBridge {
|
|
|
63
63
|
this.enabled = true;
|
|
64
64
|
/** Get 表情轻回执开关(false 时用户回复后不加表情、也不发文字兜底) */
|
|
65
65
|
this.ackReaction = true;
|
|
66
|
+
/** 忙时消息排队开关(true 时 AI 正忙的消息入队等下一轮,false 走旧的短暂存 + 过期提示) */
|
|
67
|
+
this.queueWhenBusy = true;
|
|
66
68
|
/** 绑定关系(appId -> chat_id)持久化到磁盘:多个 server 进程共享、reload 不丢 */
|
|
67
69
|
this.bindStorePath = path.join(os.homedir(), '.cursor-feedback', 'feishu-bind.json');
|
|
68
70
|
/** 飞书凭证持久化到磁盘:多个 server 进程共享、reload/重启不丢,作为凭证的全局真相源 */
|
|
@@ -71,6 +73,9 @@ class FeishuBridge {
|
|
|
71
73
|
this.cardToRequest = new Map();
|
|
72
74
|
// requestId -> 卡片 message_id(清理用)
|
|
73
75
|
this.requestToCard = new Map();
|
|
76
|
+
// requestId -> 归属项目空间:请求结束后仍保留(与卡片映射同生命周期),
|
|
77
|
+
// 供「回复已结束的旧卡片 → 忙时排队到对应项目」定位归属,随 FIFO 上界一起淘汰
|
|
78
|
+
this.requestProjects = new Map();
|
|
74
79
|
// requestId -> 摘要信息(多窗口「列清单」提示用)
|
|
75
80
|
this.pendingSummaries = new Map();
|
|
76
81
|
/** 收到用户回复时的回调(由 mcp-server 注入,负责路由 / 转发) */
|
|
@@ -92,11 +97,15 @@ class FeishuBridge {
|
|
|
92
97
|
connected: this.connected,
|
|
93
98
|
enabled: this.enabled,
|
|
94
99
|
ackReaction: this.ackReaction,
|
|
100
|
+
queueWhenBusy: this.queueWhenBusy,
|
|
95
101
|
appId: this.config?.appId || '',
|
|
96
102
|
appSecret: this.config?.appSecret || '',
|
|
97
103
|
boundChatId: this.getBoundChatId(),
|
|
98
104
|
};
|
|
99
105
|
}
|
|
106
|
+
isQueueWhenBusy() {
|
|
107
|
+
return this.queueWhenBusy;
|
|
108
|
+
}
|
|
100
109
|
isConfigured() {
|
|
101
110
|
return !!this.config;
|
|
102
111
|
}
|
|
@@ -175,9 +184,10 @@ class FeishuBridge {
|
|
|
175
184
|
* - 凭证为空:视为「关闭飞书」
|
|
176
185
|
*/
|
|
177
186
|
async configure(config) {
|
|
178
|
-
// 通知开关 / Get
|
|
187
|
+
// 通知开关 / Get 表情回执开关 / 排队开关随时可改(与凭证无关),总是更新
|
|
179
188
|
this.enabled = config.enabled !== false;
|
|
180
189
|
this.ackReaction = config.ackReaction !== false;
|
|
190
|
+
this.queueWhenBusy = config.queueWhenBusy !== false;
|
|
181
191
|
const sameCred = this.config &&
|
|
182
192
|
this.config.appId === config.appId &&
|
|
183
193
|
this.config.appSecret === config.appSecret;
|
|
@@ -514,11 +524,14 @@ class FeishuBridge {
|
|
|
514
524
|
break;
|
|
515
525
|
const oldReq = this.cardToRequest.get(oldest);
|
|
516
526
|
this.cardToRequest.delete(oldest);
|
|
517
|
-
if (oldReq !== undefined)
|
|
527
|
+
if (oldReq !== undefined) {
|
|
518
528
|
this.requestToCard.delete(oldReq);
|
|
529
|
+
this.requestProjects.delete(oldReq);
|
|
530
|
+
}
|
|
519
531
|
}
|
|
520
532
|
this.cardToRequest.set(messageId, requestId);
|
|
521
533
|
this.requestToCard.set(requestId, messageId);
|
|
534
|
+
this.requestProjects.set(requestId, projectDir);
|
|
522
535
|
this.pendingSummaries.set(requestId, { summary, projectDir });
|
|
523
536
|
}
|
|
524
537
|
return messageId;
|
|
@@ -534,6 +547,10 @@ class FeishuBridge {
|
|
|
534
547
|
return null;
|
|
535
548
|
return this.cardToRequest.get(parentId) || null;
|
|
536
549
|
}
|
|
550
|
+
/** 查某 requestId 卡片的归属项目空间(请求结束后仍可查,用于忙时排队路由) */
|
|
551
|
+
projectDirOf(requestId) {
|
|
552
|
+
return this.requestProjects.get(requestId) || null;
|
|
553
|
+
}
|
|
537
554
|
/** 本实例当前待回复的请求数(用于多窗口判断) */
|
|
538
555
|
pendingCount() {
|
|
539
556
|
return this.pendingSummaries.size;
|
package/dist/i18n/en.json
CHANGED
|
@@ -73,6 +73,8 @@
|
|
|
73
73
|
"osNotifyDesc": "Sends a system notification when the IDE is in the background.",
|
|
74
74
|
"feishuAckLabel": "Get emoji acknowledgement",
|
|
75
75
|
"feishuAckDesc": "After you reply in Feishu, the bot adds a Get emoji as acknowledgement.",
|
|
76
|
+
"feishuQueueLabel": "Queue messages while AI is busy",
|
|
77
|
+
"feishuQueueDesc": "Messages sent while the AI is busy are queued and auto-delivered on its next feedback round, marked as appended during the task.",
|
|
76
78
|
"quickReplyDefault1": "Keep waiting for my feedback",
|
|
77
79
|
"quickReplyDefault2": "End the task",
|
|
78
80
|
"quickReplyEdit": "Edit quick replies",
|
|
@@ -89,6 +91,7 @@
|
|
|
89
91
|
"notifyTestBody": "This is a test notification · click to open Cursor",
|
|
90
92
|
"toastSubmitted": "✓ Feedback sent",
|
|
91
93
|
"toastQueued": "✓ Queued — AI will receive it next round",
|
|
94
|
+
"pauseFailedEnded": "Pause failed: this request has already ended (timed out or answered)",
|
|
92
95
|
"historyBtn": "Feedback history",
|
|
93
96
|
"historyEmpty": "No history yet — submitted feedback will show up here"
|
|
94
97
|
}
|
package/dist/i18n/index.js
CHANGED
|
@@ -180,6 +180,7 @@ function getDefaultMessages() {
|
|
|
180
180
|
notifyTestBody: "This is a test notification · click to open Cursor",
|
|
181
181
|
toastSubmitted: "✓ Feedback sent",
|
|
182
182
|
toastQueued: "✓ Queued — AI will receive it next round",
|
|
183
|
+
pauseFailedEnded: "Pause failed: this request has already ended (timed out or answered)",
|
|
183
184
|
historyBtn: "Feedback history",
|
|
184
185
|
historyEmpty: "No history yet — submitted feedback will show up here"
|
|
185
186
|
};
|
package/dist/i18n/zh-CN.json
CHANGED
|
@@ -73,6 +73,8 @@
|
|
|
73
73
|
"osNotifyDesc": "IDE 切到后台时,用系统通知提醒你。",
|
|
74
74
|
"feishuAckLabel": "Get 表情回执",
|
|
75
75
|
"feishuAckDesc": "你在飞书回复后,机器人加个 Get 表情表示「已收到」。",
|
|
76
|
+
"feishuQueueLabel": "忙时消息排队",
|
|
77
|
+
"feishuQueueDesc": "AI 正忙时你发的消息先排队,等它下一轮询问时自动送达,并附「任务期间追加」提示。",
|
|
76
78
|
"quickReplyDefault1": "继续等待我的反馈",
|
|
77
79
|
"quickReplyDefault2": "结束任务",
|
|
78
80
|
"quickReplyEdit": "编辑快捷短语",
|
|
@@ -89,6 +91,7 @@
|
|
|
89
91
|
"notifyTestBody": "这是一条测试通知 · 点击试试能否唤起 Cursor",
|
|
90
92
|
"toastSubmitted": "✓ 反馈已发送",
|
|
91
93
|
"toastQueued": "✓ 已暂存,AI 下一轮将自动收到",
|
|
94
|
+
"pauseFailedEnded": "暂停失败:该请求已结束(超时或已被回复)",
|
|
92
95
|
"historyBtn": "反馈历史",
|
|
93
96
|
"historyEmpty": "还没有历史记录,提交过的反馈会出现在这里"
|
|
94
97
|
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -94,6 +94,12 @@ class McpFeedbackServer {
|
|
|
94
94
|
this.recentlyTimedOut = new Map();
|
|
95
95
|
/** 面板在超时空窗内提交的反馈:暂存到下一轮 pending 注册时立即兑现 */
|
|
96
96
|
this.panelStash = null;
|
|
97
|
+
/**
|
|
98
|
+
* 忙时消息队列:AI 正在干活(该项目空间没有等待中的反馈请求)时用户发来的飞书消息
|
|
99
|
+
* 不再丢弃,而是按项目空间排队;等 AI 下一轮调 interactive_feedback 时合并送达,
|
|
100
|
+
* 并附「任务期间追加」提示头。开关见 FeishuBridge.queueWhenBusy(面板 / FEISHU_QUEUE)。
|
|
101
|
+
*/
|
|
102
|
+
this.queuedInbound = [];
|
|
97
103
|
/** 暂存过期提示定时器:到点仍未被认领则「回复」那条消息告知没送到,避免静默丢弃 */
|
|
98
104
|
this.stashExpiryTimer = null;
|
|
99
105
|
// 最近一次被飞书回复 resolve 的请求:供插件端精确区分「飞书回复」与「超时」,
|
|
@@ -257,6 +263,15 @@ class McpFeedbackServer {
|
|
|
257
263
|
const envTimeout = process.env.MCP_FEEDBACK_TIMEOUT ? parseInt(process.env.MCP_FEEDBACK_TIMEOUT, 10) : null;
|
|
258
264
|
const timeout = envTimeout || args?.timeout || 300;
|
|
259
265
|
const requestId = this.generateRequestId();
|
|
266
|
+
// 重复投递识别:同窗口 + 同 summary + 旧请求还很新 → 视为客户端/传输层对同一次调用的
|
|
267
|
+
// 重复投递(非新一轮),直接 join 旧等待共享结果:不发新卡、不顶旧请求(顶了会让
|
|
268
|
+
// 先到的那次调用收到 SUPERSEDED、AI 误以为被新会话取代而提前收尾)。
|
|
269
|
+
const dup = this.findDuplicatePending(projectDir, summary);
|
|
270
|
+
if (dup) {
|
|
271
|
+
debugLog(`Duplicate delivery detected for request ${dup.id}; joining its wait instead of superseding`);
|
|
272
|
+
const outcome = await new Promise((res) => dup.waiters.push(res));
|
|
273
|
+
return this.outcomeToResult(outcome, dup.id);
|
|
274
|
+
}
|
|
260
275
|
// 作废上一轮残留的「僵尸」请求:单实例单窗口同时只应有一个活跃反馈请求。
|
|
261
276
|
// 旧请求多半是对话被压缩 / 客户端取消后还卡在 await 的残留(要等 timeout 才自然结束),
|
|
262
277
|
// 不清理会让 pendingCount 虚高、全局视角误判「多个窗口在等」
|
|
@@ -275,97 +290,29 @@ class McpFeedbackServer {
|
|
|
275
290
|
`request project ${normalizedProjectDir} differs and does not rewrite owner`);
|
|
276
291
|
}
|
|
277
292
|
// 创建反馈请求
|
|
278
|
-
|
|
293
|
+
const request = {
|
|
279
294
|
id: requestId,
|
|
280
295
|
summary,
|
|
281
296
|
projectDir,
|
|
282
297
|
timeout,
|
|
283
298
|
timestamp: Date.now(),
|
|
284
299
|
};
|
|
300
|
+
this.currentRequest = request;
|
|
285
301
|
debugLog(`Feedback request created: ${requestId}`);
|
|
286
302
|
debugLog(`Summary: ${summary}`);
|
|
287
303
|
debugLog(`Project: ${projectDir}`);
|
|
288
304
|
debugLog(`Timeout: ${timeout}s`);
|
|
289
305
|
debugLog(`Waiting for VS Code extension to collect feedback...`);
|
|
290
|
-
//
|
|
291
|
-
|
|
306
|
+
// 飞书:已配置则推送一张反馈请求卡片(失败不影响插件主流程)。
|
|
307
|
+
// 例外:忙时队列里已有本项目的排队消息 → 本轮会在注册后立即被队列兑现,
|
|
308
|
+
// 卡片发出去马上就过期(用户回复只会得到「已结束」),干脆不发。
|
|
309
|
+
if (this.feishu.isConfigured() && !this.hasQueuedFor(projectDir)) {
|
|
292
310
|
this.feishu.sendFeedbackCard(requestId, summary, projectDir).catch(() => { });
|
|
293
311
|
}
|
|
294
312
|
try {
|
|
295
313
|
// 等待用户反馈
|
|
296
|
-
const outcome = await this.waitForFeedback(
|
|
297
|
-
|
|
298
|
-
if (outcome.kind === 'superseded') {
|
|
299
|
-
debugLog(`Request ${requestId} superseded by a newer request; ending this turn quietly`);
|
|
300
|
-
return {
|
|
301
|
-
content: [
|
|
302
|
-
{
|
|
303
|
-
type: 'text',
|
|
304
|
-
text: '[SUPERSEDED] This feedback request was replaced by a newer interactive_feedback call (the same workspace started a new round, or another agent shares this MCP process). Do NOT call interactive_feedback again for this turn — just end your turn quietly. 【本反馈请求已被同窗口/同进程的新请求取代:不要再次调用 interactive_feedback,安静结束本轮,切勿重试。】',
|
|
305
|
-
},
|
|
306
|
-
],
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
if (outcome.kind === 'timeout') {
|
|
310
|
-
debugLog('Feedback wait window elapsed without user input');
|
|
311
|
-
// 超时续期开关:MCP_AUTO_RETRY=false 时关闭(超时即结束),默认开启(超时返回续期提醒)。
|
|
312
|
-
// 关键:这里【绝不能】说成 "cancelled"——那会让 AI 误以为用户主动取消而结束对话,
|
|
313
|
-
// 这正是“超时后 AI 直接收尾、不再续命”的根因。改为明确区分“超时 ≠ 取消”。
|
|
314
|
-
const autoRetry = this.effectiveAutoRetry();
|
|
315
|
-
const timeoutMessage = autoRetry
|
|
316
|
-
? '[TIMEOUT_KEEP_WAITING] No feedback was submitted within the wait window. This is a TIMEOUT, NOT a cancellation — the user is most likely still away from the keyboard. You MUST immediately call the interactive_feedback tool again with the same project_directory to keep waiting. Do NOT end the conversation, do NOT summarize the task as finished. Only stop calling it when the user EXPLICITLY tells you to stop. 【这是超时续期,并非用户取消:请立即用相同 project_directory 再次调用 interactive_feedback 继续等待,不要结束对话、不要当作任务完成。】'
|
|
317
|
-
: '[TIMEOUT_END] No feedback was submitted within the wait window and timeout auto-continue is disabled (MCP_AUTO_RETRY=false). You may end this turn now. 【超时未收到反馈,且已关闭超时续期(MCP_AUTO_RETRY=false),可以结束本轮。】';
|
|
318
|
-
return {
|
|
319
|
-
content: [
|
|
320
|
-
{
|
|
321
|
-
type: 'text',
|
|
322
|
-
text: timeoutMessage,
|
|
323
|
-
},
|
|
324
|
-
],
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
const result = outcome.data;
|
|
328
|
-
debugLog(`Received feedback: ${result.interactive_feedback?.substring(0, 100)}...`);
|
|
329
|
-
const contentItems = [];
|
|
330
|
-
// 构建反馈文本
|
|
331
|
-
let feedbackText = '';
|
|
332
|
-
// 添加文字反馈
|
|
333
|
-
if (result.interactive_feedback) {
|
|
334
|
-
feedbackText += `=== User Feedback ===\n${result.interactive_feedback}`;
|
|
335
|
-
}
|
|
336
|
-
// 添加附加文件路径
|
|
337
|
-
if (result.attachedFiles && result.attachedFiles.length > 0) {
|
|
338
|
-
debugLog(`Processing ${result.attachedFiles.length} attached files`);
|
|
339
|
-
feedbackText += `\n\n=== Attached Files ===\n`;
|
|
340
|
-
for (const filePath of result.attachedFiles) {
|
|
341
|
-
feedbackText += `${filePath}\n`;
|
|
342
|
-
}
|
|
343
|
-
feedbackText += `\nPlease read the above files to understand the context.`;
|
|
344
|
-
}
|
|
345
|
-
if (feedbackText) {
|
|
346
|
-
contentItems.push({
|
|
347
|
-
type: 'text',
|
|
348
|
-
text: feedbackText,
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
// 添加图片
|
|
352
|
-
if (result.images && result.images.length > 0) {
|
|
353
|
-
debugLog(`Processing ${result.images.length} images`);
|
|
354
|
-
for (const img of result.images) {
|
|
355
|
-
contentItems.push({
|
|
356
|
-
type: 'image',
|
|
357
|
-
data: img.data,
|
|
358
|
-
mimeType: this.getMimeType(img.name),
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
if (contentItems.length === 0) {
|
|
363
|
-
contentItems.push({
|
|
364
|
-
type: 'text',
|
|
365
|
-
text: 'User did not provide any feedback.',
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
return { content: contentItems };
|
|
314
|
+
const outcome = await this.waitForFeedback(request, timeout * 1000);
|
|
315
|
+
return this.outcomeToResult(outcome, requestId);
|
|
369
316
|
}
|
|
370
317
|
catch (error) {
|
|
371
318
|
debugLog(`Error collecting feedback: ${error}`);
|
|
@@ -414,6 +361,9 @@ class McpFeedbackServer {
|
|
|
414
361
|
else if (this.maybeStashForEndedCard(reqId, text, chatId, images, files, messageId)) {
|
|
415
362
|
// 卡片刚超时、AI 正要续期重调 → 暂存续接到下一轮(超时未认领由 armStashExpiryNotice 回执)
|
|
416
363
|
}
|
|
364
|
+
else if (this.queueForEndedCard(reqId, text, chatId, images, files, messageId)) {
|
|
365
|
+
// 忙时排队:AI 正忙(该项目无等待中的请求)→ 消息入队,等下一轮 feedback 自动送达
|
|
366
|
+
}
|
|
417
367
|
else {
|
|
418
368
|
// 卡片是本实例发的,但请求确实已结束(已被回复 / 超时太久)→ 明确告知
|
|
419
369
|
this.feishu.replyText(chatId, '这条反馈已经结束了(可能已超时或已被回复)。');
|
|
@@ -436,8 +386,16 @@ class McpFeedbackServer {
|
|
|
436
386
|
const remoteCount = remote.reduce((n, r) => n + r.list.length, 0);
|
|
437
387
|
const globalCount = localPending.length + remoteCount;
|
|
438
388
|
if (globalCount === 0) {
|
|
439
|
-
//
|
|
440
|
-
//
|
|
389
|
+
// 忙时排队优先:全局无人等待 = AI 大概率正在干活。定位到唯一的活跃项目窗口时
|
|
390
|
+
// 直接把消息排队给它(含回执「已排队」),多个活跃窗口则引导用户回复对应卡片。
|
|
391
|
+
// 复用上面 queryRemotePending 的扫描结果,避免二次全端口扫描。
|
|
392
|
+
if (this.feishu.isQueueWhenBusy()) {
|
|
393
|
+
const routed = await this.routeOrphanToQueue(text, chatId, images, files, messageId, remote);
|
|
394
|
+
if (routed)
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
// 抢跑兜底:定位不到活跃窗口(或队列关闭)时,退回原有短暂存——很可能 AI 正要
|
|
398
|
+
// 发起下一轮(卡片还没注册),等 pending 注册时立即兑现。
|
|
441
399
|
// 关键:此刻没有任何 AI 在等待,绝不能给「✅ 已收到」回执——那是虚假承诺,会让用户
|
|
442
400
|
// 误以为消息已被接收(实则 AI 这轮可能已结束、永远不会来认领,消息石沉大海)。
|
|
443
401
|
// 回执只在「真正送达某个等待中的请求」时给(见 tryConsumeStash → submitFromFeishu)。
|
|
@@ -456,9 +414,14 @@ class McpFeedbackServer {
|
|
|
456
414
|
}
|
|
457
415
|
}
|
|
458
416
|
else {
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
|
|
417
|
+
// 多个等待并存 → 不猜。注意措辞:多个等待可能来自同一窗口的多个对话(用户实测
|
|
418
|
+
// 「只开了一个窗口却提示多窗口」造成困惑),按「反馈请求」计数并列出项目名,
|
|
419
|
+
// 引导用户去点想回复的那张卡片——回复哪张就精确回到哪个请求。
|
|
420
|
+
const names = [
|
|
421
|
+
...localPending.map((p) => this.projectName(p.projectDir)),
|
|
422
|
+
...remote.flatMap((r) => r.list.map((x) => x.projectName)),
|
|
423
|
+
];
|
|
424
|
+
this.feishu.replyText(chatId, `当前有 ${globalCount} 个反馈请求在等待(${names.join('、')}),可能来自不同窗口或同一窗口的多个对话,没法自动判断你要回复哪个。\n请在你想回复的那张卡片上点「回复」再发,回复哪张就送达哪个请求。`);
|
|
462
425
|
}
|
|
463
426
|
});
|
|
464
427
|
}
|
|
@@ -551,7 +514,8 @@ class McpFeedbackServer {
|
|
|
551
514
|
return Promise.all(posts).then((results) => results.some(Boolean));
|
|
552
515
|
}
|
|
553
516
|
/**
|
|
554
|
-
* 向其他窗口的 server 查询各自的 pending 列表(仅项目名,用于全局视角判断 +
|
|
517
|
+
* 向其他窗口的 server 查询各自的 pending 列表(仅项目名,用于全局视角判断 + 提示文案),
|
|
518
|
+
* 以及实例归属窗口的存活状态(忙时排队用来定位「唯一活跃窗口」)。
|
|
555
519
|
* 用于无 parent_id 的「无主消息」:飞书只推给一个窗口,需汇总全局才能正确决策。
|
|
556
520
|
*/
|
|
557
521
|
queryRemotePending() {
|
|
@@ -564,21 +528,27 @@ class McpFeedbackServer {
|
|
|
564
528
|
}
|
|
565
529
|
fetchRemotePending(port) {
|
|
566
530
|
return new Promise((resolve) => {
|
|
531
|
+
const empty = { port, list: [], ownerWorkspace: null, ownerAlive: false };
|
|
567
532
|
const req = http.request({ hostname: '127.0.0.1', port, path: '/api/feishu/pending', method: 'GET', timeout: 1500 }, (res) => {
|
|
568
533
|
let body = '';
|
|
569
534
|
res.on('data', (chunk) => { body += chunk.toString(); });
|
|
570
535
|
res.on('end', () => {
|
|
571
536
|
try {
|
|
572
537
|
const j = JSON.parse(body);
|
|
573
|
-
resolve({
|
|
538
|
+
resolve({
|
|
539
|
+
port,
|
|
540
|
+
list: Array.isArray(j.list) ? j.list : [],
|
|
541
|
+
ownerWorkspace: j.ownerWorkspace || null,
|
|
542
|
+
ownerAlive: !!j.ownerAlive,
|
|
543
|
+
});
|
|
574
544
|
}
|
|
575
545
|
catch {
|
|
576
|
-
resolve(
|
|
546
|
+
resolve(empty);
|
|
577
547
|
}
|
|
578
548
|
});
|
|
579
549
|
});
|
|
580
|
-
req.on('error', () => resolve(
|
|
581
|
-
req.on('timeout', () => { req.destroy(); resolve(
|
|
550
|
+
req.on('error', () => resolve(empty));
|
|
551
|
+
req.on('timeout', () => { req.destroy(); resolve(empty); });
|
|
582
552
|
req.end();
|
|
583
553
|
});
|
|
584
554
|
}
|
|
@@ -612,17 +582,121 @@ class McpFeedbackServer {
|
|
|
612
582
|
for (const [reqId, pending] of this.pendingRequests) {
|
|
613
583
|
if (this.normalizePath(pending.projectDir) !== owner)
|
|
614
584
|
continue;
|
|
585
|
+
// 用户显式暂停的等待不作废:暂停 = 用户明确表达「保住这轮、等我回来」,
|
|
586
|
+
// 不是僵尸残留。同窗口新旧请求并存时,/api/feedback/current 按「未暂停优先」
|
|
587
|
+
// 返回,active 的结束后暂停中的会重新回到面板,可恢复可提交。
|
|
588
|
+
if (pending.paused)
|
|
589
|
+
continue;
|
|
615
590
|
clearTimeout(pending.timeout);
|
|
616
591
|
pending.resolve(null); // → superseded:旧 await 安静结束,不触发重试
|
|
617
592
|
this.feishu.clearPending(reqId);
|
|
618
593
|
this.pendingRequests.delete(reqId);
|
|
619
594
|
}
|
|
620
595
|
}
|
|
596
|
+
findDuplicatePending(projectDir, summary) {
|
|
597
|
+
const owner = this.normalizePath(projectDir);
|
|
598
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
599
|
+
if (this.normalizePath(pending.projectDir) !== owner)
|
|
600
|
+
continue;
|
|
601
|
+
if (pending.request.summary !== summary)
|
|
602
|
+
continue;
|
|
603
|
+
if (Date.now() - pending.request.timestamp > McpFeedbackServer.DUP_JOIN_MS)
|
|
604
|
+
continue;
|
|
605
|
+
return { id, waiters: pending.waiters };
|
|
606
|
+
}
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* 把等待结果翻译成 MCP 工具响应(主等待与重复投递 join 的等待共用同一段收尾语义)。
|
|
611
|
+
*/
|
|
612
|
+
outcomeToResult(outcome, requestId) {
|
|
613
|
+
// 被同窗口/同进程的新一轮请求取代:安静结束,绝不能重试(否则多 agent 互相取消、忙等刷屏)。
|
|
614
|
+
if (outcome.kind === 'superseded') {
|
|
615
|
+
debugLog(`Request ${requestId} superseded by a newer request; ending this turn quietly`);
|
|
616
|
+
return {
|
|
617
|
+
content: [
|
|
618
|
+
{
|
|
619
|
+
type: 'text',
|
|
620
|
+
text: '[SUPERSEDED] This feedback request was replaced by a newer interactive_feedback call (the same workspace started a new round, or another agent shares this MCP process). Do NOT call interactive_feedback again for this turn — just end your turn quietly. 【本反馈请求已被同窗口/同进程的新请求取代:不要再次调用 interactive_feedback,安静结束本轮,切勿重试。】',
|
|
621
|
+
},
|
|
622
|
+
],
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
if (outcome.kind === 'timeout') {
|
|
626
|
+
debugLog('Feedback wait window elapsed without user input');
|
|
627
|
+
// 超时续期开关:MCP_AUTO_RETRY=false 时关闭(超时即结束),默认开启(超时返回续期提醒)。
|
|
628
|
+
// 关键:这里【绝不能】说成 "cancelled"——那会让 AI 误以为用户主动取消而结束对话,
|
|
629
|
+
// 这正是“超时后 AI 直接收尾、不再续命”的根因。改为明确区分“超时 ≠ 取消”。
|
|
630
|
+
const autoRetry = this.effectiveAutoRetry();
|
|
631
|
+
const timeoutMessage = autoRetry
|
|
632
|
+
? '[TIMEOUT_KEEP_WAITING] No feedback was submitted within the wait window. This is a TIMEOUT, NOT a cancellation — the user is most likely still away from the keyboard. You MUST immediately call the interactive_feedback tool again with the same project_directory to keep waiting. Do NOT end the conversation, do NOT summarize the task as finished. Only stop calling it when the user EXPLICITLY tells you to stop. 【这是超时续期,并非用户取消:请立即用相同 project_directory 再次调用 interactive_feedback 继续等待,不要结束对话、不要当作任务完成。】'
|
|
633
|
+
: '[TIMEOUT_END] No feedback was submitted within the wait window and timeout auto-continue is disabled (MCP_AUTO_RETRY=false). You may end this turn now. 【超时未收到反馈,且已关闭超时续期(MCP_AUTO_RETRY=false),可以结束本轮。】';
|
|
634
|
+
return {
|
|
635
|
+
content: [
|
|
636
|
+
{
|
|
637
|
+
type: 'text',
|
|
638
|
+
text: timeoutMessage,
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
const result = outcome.data;
|
|
644
|
+
debugLog(`Received feedback: ${result.interactive_feedback?.substring(0, 100)}...`);
|
|
645
|
+
const contentItems = [];
|
|
646
|
+
// 构建反馈文本
|
|
647
|
+
let feedbackText = '';
|
|
648
|
+
// 添加文字反馈
|
|
649
|
+
if (result.interactive_feedback) {
|
|
650
|
+
feedbackText += `=== User Feedback ===\n${result.interactive_feedback}`;
|
|
651
|
+
}
|
|
652
|
+
// 添加附加文件路径
|
|
653
|
+
if (result.attachedFiles && result.attachedFiles.length > 0) {
|
|
654
|
+
debugLog(`Processing ${result.attachedFiles.length} attached files`);
|
|
655
|
+
feedbackText += `\n\n=== Attached Files ===\n`;
|
|
656
|
+
for (const filePath of result.attachedFiles) {
|
|
657
|
+
feedbackText += `${filePath}\n`;
|
|
658
|
+
}
|
|
659
|
+
feedbackText += `\nPlease read the above files to understand the context.`;
|
|
660
|
+
}
|
|
661
|
+
if (feedbackText) {
|
|
662
|
+
contentItems.push({
|
|
663
|
+
type: 'text',
|
|
664
|
+
text: feedbackText,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
// 添加图片
|
|
668
|
+
if (result.images && result.images.length > 0) {
|
|
669
|
+
debugLog(`Processing ${result.images.length} images`);
|
|
670
|
+
for (const img of result.images) {
|
|
671
|
+
contentItems.push({
|
|
672
|
+
type: 'image',
|
|
673
|
+
data: img.data,
|
|
674
|
+
mimeType: this.getMimeType(img.name),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (contentItems.length === 0) {
|
|
679
|
+
contentItems.push({
|
|
680
|
+
type: 'text',
|
|
681
|
+
text: 'User did not provide any feedback.',
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
return { content: contentItems };
|
|
685
|
+
}
|
|
621
686
|
/**
|
|
622
|
-
*
|
|
687
|
+
* 等待用户反馈:注册 pending 并挂起,结果通过 waiters 广播——
|
|
688
|
+
* 首个调用与后续 join 进来的重复投递调用都会收到同一份结果。
|
|
623
689
|
*/
|
|
624
|
-
waitForFeedback(
|
|
690
|
+
waitForFeedback(request, timeoutMs) {
|
|
625
691
|
return new Promise((resolve) => {
|
|
692
|
+
const requestId = request.id;
|
|
693
|
+
const projectDir = request.projectDir;
|
|
694
|
+
const waiters = [resolve];
|
|
695
|
+
// 广播给所有 waiter(splice 清空防重复触发:timeout 与 resolve 竞态时只结算一次)
|
|
696
|
+
const settle = (outcome) => {
|
|
697
|
+
for (const w of waiters.splice(0))
|
|
698
|
+
w(outcome);
|
|
699
|
+
};
|
|
626
700
|
const onTimeout = () => {
|
|
627
701
|
debugLog(`Request ${requestId} timed out`);
|
|
628
702
|
this.pendingRequests.delete(requestId);
|
|
@@ -633,27 +707,30 @@ class McpFeedbackServer {
|
|
|
633
707
|
this.recentlyTimedOut.delete(id);
|
|
634
708
|
}
|
|
635
709
|
// 飞书侧的清理统一交给 handleInteractiveFeedback 的 finally(clearPending),这里不再重复。
|
|
636
|
-
|
|
710
|
+
settle({ kind: 'timeout' });
|
|
637
711
|
};
|
|
638
712
|
const timeout = setTimeout(onTimeout, timeoutMs);
|
|
639
713
|
this.pendingRequests.set(requestId, {
|
|
640
714
|
// resolve 包一层:沿用「外部 resolve(feedback) / resolve(null)」旧约定,
|
|
641
715
|
// 但 null 一律映射为 superseded(被同进程新请求取代),绝不再走「超时重试」路径。
|
|
642
716
|
resolve: (value) => {
|
|
643
|
-
|
|
717
|
+
settle(value === null ? { kind: 'superseded' } : { kind: 'feedback', data: value });
|
|
644
718
|
},
|
|
645
|
-
reject: () =>
|
|
719
|
+
reject: () => settle({ kind: 'superseded' }),
|
|
646
720
|
timeout,
|
|
647
721
|
projectDir,
|
|
648
722
|
onTimeout,
|
|
649
723
|
deadline: Date.now() + timeoutMs,
|
|
650
724
|
paused: false,
|
|
651
725
|
remainingMs: timeoutMs,
|
|
726
|
+
request,
|
|
727
|
+
waiters,
|
|
652
728
|
});
|
|
653
729
|
// 抢跑兑现:空窗期的面板提交(优先,用户显式点了发送)与飞书暂存消息,
|
|
654
|
-
// 本轮 pending
|
|
730
|
+
// 本轮 pending 一注册立即作为回复提交;最后是忙时队列里排队的追加消息
|
|
655
731
|
this.tryConsumePanelStash(requestId);
|
|
656
732
|
this.tryConsumeStash(requestId);
|
|
733
|
+
this.tryConsumeQueue(requestId);
|
|
657
734
|
});
|
|
658
735
|
}
|
|
659
736
|
/**
|
|
@@ -683,20 +760,45 @@ class McpFeedbackServer {
|
|
|
683
760
|
remainingMs: p.paused ? p.remainingMs : Math.max(0, p.deadline - Date.now()),
|
|
684
761
|
};
|
|
685
762
|
}
|
|
686
|
-
/**
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
if (!cur)
|
|
763
|
+
/** 指定请求的暂停态快照(随 /api/feedback/current 下发,供面板重建后恢复显示) */
|
|
764
|
+
getPauseStateFor(requestId) {
|
|
765
|
+
if (!requestId)
|
|
690
766
|
return null;
|
|
691
|
-
const p = this.pendingRequests.get(
|
|
767
|
+
const p = this.pendingRequests.get(requestId);
|
|
692
768
|
if (!p)
|
|
693
769
|
return null;
|
|
694
770
|
return {
|
|
695
|
-
requestId
|
|
771
|
+
requestId,
|
|
696
772
|
paused: p.paused,
|
|
697
773
|
remainingMs: p.paused ? p.remainingMs : Math.max(0, p.deadline - Date.now()),
|
|
698
774
|
};
|
|
699
775
|
}
|
|
776
|
+
/**
|
|
777
|
+
* 按窗口(workspace)从 pendingRequests 挑该窗口该看到的请求。
|
|
778
|
+
* 修复「多窗口共用一个 MCP 进程时 currentRequest 单值槽互相覆盖、被覆盖的窗口面板
|
|
779
|
+
* 显示不出自己的等待(只有飞书收到)」:每个窗口轮询时按自己的路径取自己的请求。
|
|
780
|
+
* 多个匹配时:未暂停的优先(active 请求先服务);同暂停态取最新——active 的结束后
|
|
781
|
+
* 暂停中的会自然回到面板,用户可恢复。
|
|
782
|
+
*/
|
|
783
|
+
pickRequestForWorkspace(normalizedWs) {
|
|
784
|
+
let best = null;
|
|
785
|
+
for (const [, p] of this.pendingRequests) {
|
|
786
|
+
if (!this.pathsRelated(this.normalizePath(p.projectDir), normalizedWs))
|
|
787
|
+
continue;
|
|
788
|
+
if (!best) {
|
|
789
|
+
best = p;
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (best.paused !== p.paused) {
|
|
793
|
+
if (best.paused)
|
|
794
|
+
best = p;
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (p.request.timestamp > best.request.timestamp)
|
|
798
|
+
best = p;
|
|
799
|
+
}
|
|
800
|
+
return best ? best.request : null;
|
|
801
|
+
}
|
|
700
802
|
/**
|
|
701
803
|
* 写入/合并抢跑暂存:短时间内连发多条(含图文拆条后超出合并窗口的)合并为一条,
|
|
702
804
|
* 绝不让后一条覆盖前一条(旧实现会把第一条静默丢掉)。
|
|
@@ -821,6 +923,202 @@ class McpFeedbackServer {
|
|
|
821
923
|
this.pendingRequests.delete(requestId);
|
|
822
924
|
this.feishu.clearPending(requestId);
|
|
823
925
|
}
|
|
926
|
+
// ==================== 忙时消息队列 ====================
|
|
927
|
+
/** 某项目空间当前是否有等待中的反馈请求(路径互为前缀视为同一窗口语境) */
|
|
928
|
+
hasPendingForProject(projectDir) {
|
|
929
|
+
const owner = this.normalizePath(projectDir);
|
|
930
|
+
for (const [, p] of this.pendingRequests) {
|
|
931
|
+
if (this.pathsRelated(this.normalizePath(p.projectDir), owner))
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
934
|
+
return false;
|
|
935
|
+
}
|
|
936
|
+
/** 队列里是否有属于某项目空间的排队消息 */
|
|
937
|
+
hasQueuedFor(projectDir) {
|
|
938
|
+
if (!this.feishu.isQueueWhenBusy())
|
|
939
|
+
return false;
|
|
940
|
+
const owner = this.normalizePath(projectDir);
|
|
941
|
+
return this.queuedInbound.some((q) => this.pathsRelated(this.normalizePath(q.forProjectDir), owner));
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* 用户回复了一张「已结束」的卡片(不在超时续接窗口内)→ 忙时排队:
|
|
945
|
+
* - 该项目已有新一轮在等 → 引导回复最新卡片(避免旧回复窜入错误轮次),视为已处理;
|
|
946
|
+
* - 该项目没有等待中的请求(AI 正忙)→ 入队,等下一轮 feedback 自动送达。
|
|
947
|
+
* 返回 true 表示已处理(排队或已引导),false 表示队列关闭 / 定位不到归属,走原有「已结束」回执。
|
|
948
|
+
*/
|
|
949
|
+
queueForEndedCard(reqId, text, chatId, images, files, messageId) {
|
|
950
|
+
if (!this.feishu.isQueueWhenBusy())
|
|
951
|
+
return false;
|
|
952
|
+
const cardProject = this.feishu.projectDirOf(reqId);
|
|
953
|
+
if (!cardProject)
|
|
954
|
+
return false;
|
|
955
|
+
if (this.hasPendingForProject(cardProject)) {
|
|
956
|
+
this.feishu.replyToMessage(messageId || undefined, chatId, '这张卡片的反馈已结束,且该项目有新的反馈卡片正在等待。请「回复」最新的那张卡片。');
|
|
957
|
+
return true;
|
|
958
|
+
}
|
|
959
|
+
this.enqueueInbound(text, chatId, images, files, messageId, cardProject);
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* 消息入队 + 回执用户「已排队」。每条消息带 60 分钟过期兜底:
|
|
964
|
+
* 到点仍未被 AI 读取(对话可能已结束)→ 引用回复告知未送达,绝不静默丢弃。
|
|
965
|
+
*/
|
|
966
|
+
enqueueInbound(text, chatId, images, files, messageId, forProjectDir) {
|
|
967
|
+
// 上限保护:挤出最老的一条并告知未送达
|
|
968
|
+
while (this.queuedInbound.length >= McpFeedbackServer.QUEUE_MAX) {
|
|
969
|
+
const dropped = this.queuedInbound.shift();
|
|
970
|
+
if (!dropped)
|
|
971
|
+
break;
|
|
972
|
+
clearTimeout(dropped.expiryTimer);
|
|
973
|
+
this.feishu.replyToMessage(dropped.messageId || undefined, dropped.chatId, '⚠️ 排队消息过多,这条消息已被挤出队列、未送达 AI,请稍后重发。');
|
|
974
|
+
}
|
|
975
|
+
const item = {
|
|
976
|
+
text,
|
|
977
|
+
chatId,
|
|
978
|
+
images,
|
|
979
|
+
files,
|
|
980
|
+
at: Date.now(),
|
|
981
|
+
messageId,
|
|
982
|
+
forProjectDir,
|
|
983
|
+
expiryTimer: undefined,
|
|
984
|
+
};
|
|
985
|
+
item.expiryTimer = setTimeout(() => {
|
|
986
|
+
const idx = this.queuedInbound.indexOf(item);
|
|
987
|
+
if (idx < 0)
|
|
988
|
+
return; // 已被消费
|
|
989
|
+
this.queuedInbound.splice(idx, 1);
|
|
990
|
+
this.feishu.replyToMessage(item.messageId || undefined, item.chatId, '⚠️ 这条排队消息等了 60 分钟仍未被 AI 读取(对话可能已结束),未能送达。需要的话请在 AI 下次询问时重发。');
|
|
991
|
+
}, McpFeedbackServer.QUEUE_TTL_MS);
|
|
992
|
+
item.expiryTimer.unref?.();
|
|
993
|
+
this.queuedInbound.push(item);
|
|
994
|
+
debugLog(`Inbound queued for busy AI (project=${forProjectDir}, queueSize=${this.queuedInbound.length})`);
|
|
995
|
+
this.feishu.replyToMessage(messageId || undefined, chatId, `🤖 AI 正在工作中,这条消息已排队,将在「${this.projectName(forProjectDir)}」当前任务完成后自动读取。`);
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* 队列兑现:新一轮 pending 注册时,把属于该项目空间的所有排队消息合并成一次反馈送达,
|
|
999
|
+
* 正文附「任务期间追加」提示头,并给每条排队消息补 ✅ 送达回执。
|
|
1000
|
+
*/
|
|
1001
|
+
tryConsumeQueue(requestId) {
|
|
1002
|
+
const pending = this.pendingRequests.get(requestId);
|
|
1003
|
+
if (!pending)
|
|
1004
|
+
return;
|
|
1005
|
+
if (this.queuedInbound.length === 0)
|
|
1006
|
+
return;
|
|
1007
|
+
const owner = this.normalizePath(pending.projectDir);
|
|
1008
|
+
const matched = this.queuedInbound.filter((q) => this.pathsRelated(this.normalizePath(q.forProjectDir), owner));
|
|
1009
|
+
if (matched.length === 0)
|
|
1010
|
+
return;
|
|
1011
|
+
this.queuedInbound = this.queuedInbound.filter((q) => !matched.includes(q));
|
|
1012
|
+
for (const m of matched)
|
|
1013
|
+
clearTimeout(m.expiryTimer);
|
|
1014
|
+
const two = (n) => String(n).padStart(2, '0');
|
|
1015
|
+
const fmt = (at) => {
|
|
1016
|
+
const d = new Date(at);
|
|
1017
|
+
return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`;
|
|
1018
|
+
};
|
|
1019
|
+
const lines = matched.map((m) => {
|
|
1020
|
+
const body = m.text || (m.images.length || m.files.length ? '[图片/附件]' : '');
|
|
1021
|
+
return `[${fmt(m.at)}] ${body}`;
|
|
1022
|
+
});
|
|
1023
|
+
const text = '[QUEUED_MESSAGES] 以下是用户在你执行上一轮任务期间「追加」发送的消息(当时你正忙,消息已排队暂存)。' +
|
|
1024
|
+
'注意:这些内容不是对你最新一轮工作摘要的回复,请结合任务上下文阅读并处理;' +
|
|
1025
|
+
'如与你摘要中的询问冲突,以用户追加内容为准。处理完后照常调用 interactive_feedback 继续对话。\n\n' +
|
|
1026
|
+
`=== 追加消息(${matched.length} 条)===\n` +
|
|
1027
|
+
lines.join('\n\n');
|
|
1028
|
+
debugLog(`Consuming ${matched.length} queued message(s) for request: ${requestId}`);
|
|
1029
|
+
clearTimeout(pending.timeout);
|
|
1030
|
+
pending.resolve({
|
|
1031
|
+
interactive_feedback: text,
|
|
1032
|
+
images: matched.flatMap((m) => m.images),
|
|
1033
|
+
attachedFiles: matched.flatMap((m) => m.files),
|
|
1034
|
+
project_directory: pending.projectDir,
|
|
1035
|
+
});
|
|
1036
|
+
this.pendingRequests.delete(requestId);
|
|
1037
|
+
this.feishu.clearPending(requestId);
|
|
1038
|
+
// 标记为「飞书渠道 resolve」:插件面板据此重置,避免对着已消失的请求提交
|
|
1039
|
+
this.lastFeishuResolved = { id: requestId, at: Date.now() };
|
|
1040
|
+
// 送达回执:给每条排队消息补 ✅ 表情
|
|
1041
|
+
for (const m of matched) {
|
|
1042
|
+
this.feishu.reactDone(m.messageId || undefined, m.chatId);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
/** 「我的窗口」是否仍活着(无插件 host 无从判定,视为活跃) */
|
|
1046
|
+
isOwnerAlive() {
|
|
1047
|
+
if (!this.everOwnerPolled)
|
|
1048
|
+
return true;
|
|
1049
|
+
return Date.now() - this.lastOwnerPollTime <= McpFeedbackServer.OWNER_IDLE_MS;
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* 「无主消息」的忙时排队路由:全局无人等待时,汇总所有实例的活跃项目窗口——
|
|
1053
|
+
* - 恰好 1 个 → 消息排队给它(本地直接入队;远程实例经 /api/feishu/enqueue 转发);
|
|
1054
|
+
* - 多个 → 回执引导用户回复对应项目的卡片(回复旧卡片也会正确排队到该项目);
|
|
1055
|
+
* - 0 个 → 返回 false,调用方退回原有短暂存兜底。
|
|
1056
|
+
*/
|
|
1057
|
+
async routeOrphanToQueue(text, chatId, images, files, messageId, remotes) {
|
|
1058
|
+
const candidates = [];
|
|
1059
|
+
if (this.ownerWorkspace && this.isOwnerAlive()) {
|
|
1060
|
+
candidates.push({ port: null, workspace: this.ownerWorkspace });
|
|
1061
|
+
}
|
|
1062
|
+
for (const r of remotes) {
|
|
1063
|
+
if (r.ownerAlive && r.ownerWorkspace) {
|
|
1064
|
+
candidates.push({ port: r.port, workspace: r.ownerWorkspace });
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
// 按 workspace 去重(同窗口多对话共用/多实例只算一个活跃窗口;本地优先)
|
|
1068
|
+
const seen = new Set();
|
|
1069
|
+
const uniq = [];
|
|
1070
|
+
for (const c of candidates) {
|
|
1071
|
+
const key = this.normalizePath(c.workspace);
|
|
1072
|
+
if (seen.has(key))
|
|
1073
|
+
continue;
|
|
1074
|
+
seen.add(key);
|
|
1075
|
+
uniq.push(c);
|
|
1076
|
+
}
|
|
1077
|
+
if (uniq.length === 1) {
|
|
1078
|
+
const target = uniq[0];
|
|
1079
|
+
if (target.port === null) {
|
|
1080
|
+
this.enqueueInbound(text, chatId, images, files, messageId, target.workspace);
|
|
1081
|
+
return true;
|
|
1082
|
+
}
|
|
1083
|
+
return this.forwardEnqueue(target.port, text, chatId, images, files, messageId);
|
|
1084
|
+
}
|
|
1085
|
+
if (uniq.length > 1) {
|
|
1086
|
+
const names = uniq.map((c) => this.projectName(c.workspace));
|
|
1087
|
+
this.feishu.replyToMessage(messageId || undefined, chatId, `当前有 ${uniq.length} 个项目窗口在工作(${names.join('、')}),没法判断这条消息要发给谁。\n` +
|
|
1088
|
+
'请「回复」你要发送的那个项目的卡片(旧卡片也行),消息会排队到对应项目。');
|
|
1089
|
+
return true;
|
|
1090
|
+
}
|
|
1091
|
+
return false;
|
|
1092
|
+
}
|
|
1093
|
+
/** 把无主消息转发给远程实例排队(远程会自己入队并回执用户)。返回是否排队成功 */
|
|
1094
|
+
forwardEnqueue(port, text, chatId, images, files, messageId) {
|
|
1095
|
+
const body = JSON.stringify({ text, chatId, images, attachedFiles: files, messageId });
|
|
1096
|
+
return new Promise((resolve) => {
|
|
1097
|
+
const req = http.request({
|
|
1098
|
+
hostname: '127.0.0.1',
|
|
1099
|
+
port,
|
|
1100
|
+
path: '/api/feishu/enqueue',
|
|
1101
|
+
method: 'POST',
|
|
1102
|
+
timeout: 2000,
|
|
1103
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
|
|
1104
|
+
}, (res) => {
|
|
1105
|
+
let resBody = '';
|
|
1106
|
+
res.on('data', (chunk) => { resBody += chunk.toString(); });
|
|
1107
|
+
res.on('end', () => {
|
|
1108
|
+
try {
|
|
1109
|
+
resolve(!!JSON.parse(resBody).queued);
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
resolve(false);
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
});
|
|
1116
|
+
req.on('error', () => resolve(false));
|
|
1117
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
1118
|
+
req.write(body);
|
|
1119
|
+
req.end();
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
824
1122
|
/**
|
|
825
1123
|
* 处理获取系统信息请求
|
|
826
1124
|
*/
|
|
@@ -963,6 +1261,7 @@ class McpFeedbackServer {
|
|
|
963
1261
|
if (req.method === 'GET' && req.url?.startsWith('/api/feedback/current')) {
|
|
964
1262
|
// autoRetry 不再从轮询 query 同步(曾致多窗口互相覆盖、抖动):
|
|
965
1263
|
// 改由 POST /api/settings/autoRetry 广播 + 磁盘真相源,poll 只回读做 UI 回显。
|
|
1264
|
+
let pollWs = '';
|
|
966
1265
|
try {
|
|
967
1266
|
const u = new URL(req.url, 'http://127.0.0.1');
|
|
968
1267
|
// 「我的窗口」心跳:仅当轮询带的 workspace 匹配本实例归属时刷新。
|
|
@@ -971,6 +1270,7 @@ class McpFeedbackServer {
|
|
|
971
1270
|
// 匹配放宽为「路径互为前缀」:AI 传的 project_directory 可能是窗口工作区的子目录。
|
|
972
1271
|
// 命中即确认 owner 身份(ownerConfirmed),此后 owner 不再被后续调用改写。
|
|
973
1272
|
const ws = this.normalizePath(u.searchParams.get('workspace') || '');
|
|
1273
|
+
pollWs = ws;
|
|
974
1274
|
if (ws && this.ownerWorkspace && this.pathsRelated(ws, this.ownerWorkspace)) {
|
|
975
1275
|
this.lastOwnerPollTime = Date.now();
|
|
976
1276
|
this.everOwnerPolled = true;
|
|
@@ -980,16 +1280,19 @@ class McpFeedbackServer {
|
|
|
980
1280
|
catch {
|
|
981
1281
|
// 忽略解析错误
|
|
982
1282
|
}
|
|
1283
|
+
// 按窗口挑请求:多窗口/多对话共用本进程时各窗口各看各的等待,互不覆盖。
|
|
1284
|
+
// 不带 workspace(无工作区窗口 / 旧版插件)时回退全局 currentRequest 老行为。
|
|
1285
|
+
const chosen = pollWs ? this.pickRequestForWorkspace(pollWs) : (this.currentRequest || null);
|
|
983
1286
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
984
|
-
//
|
|
1287
|
+
// 返回该窗口的请求、ownerWorkspace、startTime,以及当前生效的 autoRetry(供 UI 初始同步)
|
|
985
1288
|
res.end(JSON.stringify({
|
|
986
|
-
request:
|
|
1289
|
+
request: chosen,
|
|
987
1290
|
ownerWorkspace: this.ownerWorkspace,
|
|
988
1291
|
startTime: this.startTime,
|
|
989
1292
|
autoRetry: this.autoRetryOverride !== null ? this.autoRetryOverride : (process.env.MCP_AUTO_RETRY !== 'false'),
|
|
990
1293
|
feishu: this.feishu.getStatus(),
|
|
991
1294
|
feishuResolvedId: (this.lastFeishuResolved && Date.now() - this.lastFeishuResolved.at < 30000) ? this.lastFeishuResolved.id : null,
|
|
992
|
-
pause: this.
|
|
1295
|
+
pause: this.getPauseStateFor(chosen?.id),
|
|
993
1296
|
}));
|
|
994
1297
|
return;
|
|
995
1298
|
}
|
|
@@ -1054,6 +1357,7 @@ class McpFeedbackServer {
|
|
|
1054
1357
|
appSecret: cfg.appSecret || '',
|
|
1055
1358
|
enabled: cfg.enabled,
|
|
1056
1359
|
ackReaction: cfg.ackReaction,
|
|
1360
|
+
queueWhenBusy: cfg.queueWhenBusy,
|
|
1057
1361
|
touched: true,
|
|
1058
1362
|
});
|
|
1059
1363
|
await this.feishu.configure(cfg);
|
|
@@ -1144,6 +1448,12 @@ class McpFeedbackServer {
|
|
|
1144
1448
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1145
1449
|
res.end(JSON.stringify({ handled: true }));
|
|
1146
1450
|
}
|
|
1451
|
+
else if (reqId &&
|
|
1452
|
+
this.queueForEndedCard(reqId, text, chatId, images || [], attachedFiles || [], messageId || '')) {
|
|
1453
|
+
// 本实例的卡片、请求已结束且 AI 正忙 → 消息已排队(或已引导回复新卡片)
|
|
1454
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1455
|
+
res.end(JSON.stringify({ handled: true }));
|
|
1456
|
+
}
|
|
1147
1457
|
else {
|
|
1148
1458
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1149
1459
|
res.end(JSON.stringify({ handled: false }));
|
|
@@ -1166,7 +1476,13 @@ class McpFeedbackServer {
|
|
|
1166
1476
|
? []
|
|
1167
1477
|
: this.feishu.listPending().map((p) => ({ projectName: this.projectName(p.projectDir) }));
|
|
1168
1478
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1169
|
-
|
|
1479
|
+
// ownerWorkspace / ownerAlive:供其他实例的忙时排队路由定位「活跃项目窗口」
|
|
1480
|
+
res.end(JSON.stringify({
|
|
1481
|
+
count: list.length,
|
|
1482
|
+
list,
|
|
1483
|
+
ownerWorkspace: this.ownerWorkspace,
|
|
1484
|
+
ownerAlive: !!this.ownerWorkspace && !ownerGone,
|
|
1485
|
+
}));
|
|
1170
1486
|
return;
|
|
1171
1487
|
}
|
|
1172
1488
|
// 接收「无主消息」转发:仅当本实例恰好持有唯一 pending 时认领提交
|
|
@@ -1195,6 +1511,31 @@ class McpFeedbackServer {
|
|
|
1195
1511
|
});
|
|
1196
1512
|
return;
|
|
1197
1513
|
}
|
|
1514
|
+
// 接收「无主消息」的忙时排队转发:本实例是全局唯一活跃窗口时,把消息排进本实例队列
|
|
1515
|
+
// (入队与「已排队」回执都由本实例完成,转发方只关心 queued 结果)
|
|
1516
|
+
if (req.method === 'POST' && req.url === '/api/feishu/enqueue') {
|
|
1517
|
+
let body = '';
|
|
1518
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
1519
|
+
req.on('end', () => {
|
|
1520
|
+
try {
|
|
1521
|
+
const { text, chatId, images, attachedFiles, messageId } = JSON.parse(body);
|
|
1522
|
+
if (this.feishu.isQueueWhenBusy() && this.ownerWorkspace) {
|
|
1523
|
+
this.enqueueInbound(text, chatId, images || [], attachedFiles || [], messageId || '', this.ownerWorkspace);
|
|
1524
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1525
|
+
res.end(JSON.stringify({ queued: true }));
|
|
1526
|
+
}
|
|
1527
|
+
else {
|
|
1528
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1529
|
+
res.end(JSON.stringify({ queued: false }));
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
catch {
|
|
1533
|
+
res.writeHead(400);
|
|
1534
|
+
res.end('{}');
|
|
1535
|
+
}
|
|
1536
|
+
});
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1198
1539
|
// 健康检查
|
|
1199
1540
|
if (req.method === 'GET' && req.url === '/api/health') {
|
|
1200
1541
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
@@ -1292,6 +1633,7 @@ class McpFeedbackServer {
|
|
|
1292
1633
|
appSecret,
|
|
1293
1634
|
enabled: process.env.FEISHU_ENABLED !== 'false',
|
|
1294
1635
|
ackReaction: process.env.FEISHU_ACK !== 'false',
|
|
1636
|
+
queueWhenBusy: process.env.FEISHU_QUEUE !== 'false',
|
|
1295
1637
|
};
|
|
1296
1638
|
}
|
|
1297
1639
|
/**
|
|
@@ -1421,6 +1763,11 @@ class McpFeedbackServer {
|
|
|
1421
1763
|
pending.resolve(null);
|
|
1422
1764
|
}
|
|
1423
1765
|
this.pendingRequests.clear();
|
|
1766
|
+
// 清理忙时队列的过期定时器(进程将退出,消息由 60 分钟兜底逻辑以外的重启场景自然失效)
|
|
1767
|
+
for (const q of this.queuedInbound) {
|
|
1768
|
+
clearTimeout(q.expiryTimer);
|
|
1769
|
+
}
|
|
1770
|
+
this.queuedInbound = [];
|
|
1424
1771
|
// 关闭 MCP 服务器
|
|
1425
1772
|
this.server.close();
|
|
1426
1773
|
debugLog('Server stopped');
|
|
@@ -1436,11 +1783,21 @@ McpFeedbackServer.STASH_TTL_MS = 5000;
|
|
|
1436
1783
|
* 都应暂存并续接到下一轮,而不是报「Request not found / 已结束」把用户内容丢掉。
|
|
1437
1784
|
*/
|
|
1438
1785
|
McpFeedbackServer.REJOIN_TTL_MS = 15000;
|
|
1786
|
+
/** 队列消息未被读取的兜底时长:超过视为对话已结束,回执用户避免静默丢失 */
|
|
1787
|
+
McpFeedbackServer.QUEUE_TTL_MS = 60 * 60 * 1000;
|
|
1788
|
+
/** 队列长度上限:极端情况下防止无限堆积 */
|
|
1789
|
+
McpFeedbackServer.QUEUE_MAX = 100;
|
|
1439
1790
|
/**
|
|
1440
1791
|
* 「我的窗口」判定为已关闭的空闲阈值:本实例曾被自己窗口插件轮询,但超过此时长没再收到
|
|
1441
1792
|
*(只剩别的窗口在扫端口)→ 视为我的窗口已关、本实例是僵尸:不再上报 pending,避免污染全局计数。
|
|
1442
1793
|
*/
|
|
1443
1794
|
McpFeedbackServer.OWNER_IDLE_MS = 12000;
|
|
1795
|
+
/**
|
|
1796
|
+
* 查找可 join 的「重复投递」等待:同窗口 + 同 summary + 注册时间在 DUP_JOIN_MS 内。
|
|
1797
|
+
* AI 每一轮的 summary 几乎不可能与上一轮一字不差,短窗口内完全相同基本可断定为
|
|
1798
|
+
* 客户端/传输层对同一次 tool call 的重复投递。
|
|
1799
|
+
*/
|
|
1800
|
+
McpFeedbackServer.DUP_JOIN_MS = 90000;
|
|
1444
1801
|
// 主函数
|
|
1445
1802
|
async function main() {
|
|
1446
1803
|
const port = 61927;
|
package/dist/webview/index.html
CHANGED
|
@@ -199,6 +199,14 @@
|
|
|
199
199
|
</label>
|
|
200
200
|
<p class="feishu-modal__desc feishu-modal__desc--sub">{{i18n.feishuAckDesc}}</p>
|
|
201
201
|
</div>
|
|
202
|
+
<div id="feishuQueueSub" class="feishu-subitem">
|
|
203
|
+
<label class="feishu-switch">
|
|
204
|
+
<span class="feishu-switch__label">{{i18n.feishuQueueLabel}}</span>
|
|
205
|
+
<input id="feishuQueueToggle" type="checkbox" class="feishu-switch__input">
|
|
206
|
+
<span class="feishu-switch__track"><span class="feishu-switch__thumb"></span></span>
|
|
207
|
+
</label>
|
|
208
|
+
<p class="feishu-modal__desc feishu-modal__desc--sub">{{i18n.feishuQueueDesc}}</p>
|
|
209
|
+
</div>
|
|
202
210
|
<label class="feishu-field">
|
|
203
211
|
<span class="feishu-field__label">{{i18n.feishuAppId}}</span>
|
|
204
212
|
<input id="feishuAppId" class="feishu-input" type="text" placeholder="{{i18n.feishuAppIdPlaceholder}}" autocomplete="off" spellcheck="false">
|
package/dist/webview/script.js
CHANGED
|
@@ -98,8 +98,10 @@
|
|
|
98
98
|
const systemNotifyToggle = document.getElementById('systemNotifyToggle');
|
|
99
99
|
const osNotifyToggle = document.getElementById('osNotifyToggle');
|
|
100
100
|
const feishuAckToggle = document.getElementById('feishuAckToggle');
|
|
101
|
+
const feishuQueueToggle = document.getElementById('feishuQueueToggle');
|
|
101
102
|
const osNotifySub = document.getElementById('osNotifySub');
|
|
102
103
|
const feishuAckSub = document.getElementById('feishuAckSub');
|
|
104
|
+
const feishuQueueSub = document.getElementById('feishuQueueSub');
|
|
103
105
|
const notifyTestBtn = document.getElementById('notifyTestBtn');
|
|
104
106
|
const notifyTestHint = document.getElementById('notifyTestHint');
|
|
105
107
|
const historyBtn = document.getElementById('historyBtn');
|
|
@@ -239,6 +241,9 @@
|
|
|
239
241
|
feishuAckToggle.addEventListener('change', () => {
|
|
240
242
|
vscode.postMessage({ type: 'toggleFeishuAck', payload: { enabled: feishuAckToggle.checked } });
|
|
241
243
|
});
|
|
244
|
+
feishuQueueToggle.addEventListener('change', () => {
|
|
245
|
+
vscode.postMessage({ type: 'toggleFeishuQueue', payload: { enabled: feishuQueueToggle.checked } });
|
|
246
|
+
});
|
|
242
247
|
|
|
243
248
|
// 发送测试通知:一键验证系统通知链路通不通(权限被拒时收不到,配合上方排查提示定位)
|
|
244
249
|
let notifyTestHintTimer = null;
|
|
@@ -257,6 +262,8 @@
|
|
|
257
262
|
const feishuOn = feishuEnabledToggle.checked;
|
|
258
263
|
feishuAckToggle.disabled = !feishuOn;
|
|
259
264
|
if (feishuAckSub) feishuAckSub.classList.toggle('is-disabled', !feishuOn);
|
|
265
|
+
feishuQueueToggle.disabled = !feishuOn;
|
|
266
|
+
if (feishuQueueSub) feishuQueueSub.classList.toggle('is-disabled', !feishuOn);
|
|
260
267
|
}
|
|
261
268
|
|
|
262
269
|
function updateFeishuUI(state) {
|
|
@@ -276,6 +283,9 @@
|
|
|
276
283
|
if (typeof state.feishuAck === 'boolean') {
|
|
277
284
|
feishuAckToggle.checked = state.feishuAck;
|
|
278
285
|
}
|
|
286
|
+
if (typeof state.feishuQueue === 'boolean') {
|
|
287
|
+
feishuQueueToggle.checked = state.feishuQueue;
|
|
288
|
+
}
|
|
279
289
|
syncSubSwitchDisabled();
|
|
280
290
|
feishuStatusEl.classList.toggle('is-configured', !!state.configured && !state.bound);
|
|
281
291
|
feishuStatusEl.classList.toggle('is-bound', !!state.bound);
|
|
@@ -1263,6 +1273,11 @@
|
|
|
1263
1273
|
? (i18n.toastQueued || 'Queued — will be delivered to AI next round')
|
|
1264
1274
|
: (i18n.toastSubmitted || 'Feedback sent'));
|
|
1265
1275
|
break;
|
|
1276
|
+
|
|
1277
|
+
case 'toast':
|
|
1278
|
+
// extension 侧的通用轻提示(如暂停失败:请求已结束)
|
|
1279
|
+
if (message.payload && message.payload.text) showToast(message.payload.text);
|
|
1280
|
+
break;
|
|
1266
1281
|
}
|
|
1267
1282
|
});
|
|
1268
1283
|
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "cursor-feedback",
|
|
3
3
|
"displayName": "Cursor Feedback",
|
|
4
4
|
"description": "One Cursor conversation, unlimited AI interactions - Save your monthly request quota! Interactive feedback loop for AI chat via MCP",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.2.0",
|
|
6
6
|
"icon": "icon.png",
|
|
7
7
|
"author": "jianger666",
|
|
8
8
|
"license": "MIT",
|