cursor-feedback 2.0.5 → 2.1.1
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 +19 -0
- package/dist/extension.js +152 -19
- package/dist/i18n/en.json +19 -1
- package/dist/i18n/index.js +19 -1
- package/dist/i18n/zh-CN.json +19 -1
- package/dist/mcp-server.js +297 -44
- package/dist/webview/index.html +22 -0
- package/dist/webview/script.js +351 -10
- package/dist/webview/styles.css +163 -0
- package/package.json +1 -1
package/dist/mcp-server.js
CHANGED
|
@@ -90,6 +90,10 @@ class McpFeedbackServer {
|
|
|
90
90
|
* 暂存后等下一轮 pending 一注册立即兑现,避免回复石沉大海(修复用户反馈的竞态 bug)。
|
|
91
91
|
*/
|
|
92
92
|
this.stashedInbound = null;
|
|
93
|
+
/** 最近超时结束的请求(id → 归属窗口/时刻),供面板提交与飞书旧卡片回复「续接」下一轮 */
|
|
94
|
+
this.recentlyTimedOut = new Map();
|
|
95
|
+
/** 面板在超时空窗内提交的反馈:暂存到下一轮 pending 注册时立即兑现 */
|
|
96
|
+
this.panelStash = null;
|
|
93
97
|
/** 暂存过期提示定时器:到点仍未被认领则「回复」那条消息告知没送到,避免静默丢弃 */
|
|
94
98
|
this.stashExpiryTimer = null;
|
|
95
99
|
// 最近一次被飞书回复 resolve 的请求:供插件端精确区分「飞书回复」与「超时」,
|
|
@@ -104,6 +108,10 @@ class McpFeedbackServer {
|
|
|
104
108
|
// 所属工作区(只在 AI 调用 feedback 时设置)
|
|
105
109
|
// 只有来自同一工作区的轮询才会更新活动时间
|
|
106
110
|
this.ownerWorkspace = null;
|
|
111
|
+
// owner 身份是否已被真实窗口的轮询验证过。验证后 ownerWorkspace 不再被后续调用改写:
|
|
112
|
+
// AI 传的 project_directory 是「正在操作的项目」,不一定等于窗口工作区(同窗口操作兄弟/子目录很常见),
|
|
113
|
+
// 每次改写会让插件心跳失配 → 实例被防线 3 当僵尸误杀(等待反馈中直接 Connection closed)。
|
|
114
|
+
this.ownerConfirmed = false;
|
|
107
115
|
// Server 启动时间
|
|
108
116
|
this.startTime = Date.now();
|
|
109
117
|
// 最近一次活动时间(任意 HTTP 轮询 / MCP 调用都会刷新)
|
|
@@ -118,6 +126,9 @@ class McpFeedbackServer {
|
|
|
118
126
|
this.everOwnerPolled = false;
|
|
119
127
|
// 看门狗定时器(兜底退出,防止进程残留 / CPU 占满)
|
|
120
128
|
this.watchdogTimer = null;
|
|
129
|
+
// stop() 防重入:server.close() 会触发 transport.onclose → 回调里又调 stop(),
|
|
130
|
+
// 无标志位会无限递归直至 "Maximum call stack size exceeded"(日志曾刷出数万条 Stopping server...)
|
|
131
|
+
this.stopping = false;
|
|
121
132
|
this.port = port;
|
|
122
133
|
this.basePort = port;
|
|
123
134
|
this.server = new index_js_1.Server({
|
|
@@ -251,9 +262,18 @@ class McpFeedbackServer {
|
|
|
251
262
|
// 不清理会让 pendingCount 虚高、全局视角误判「多个窗口在等」
|
|
252
263
|
//(即你看到的两个一模一样的 cursor-feedback-extension)。
|
|
253
264
|
this.cancelStalePending(projectDir);
|
|
254
|
-
// AI 调用 feedback 时设置 ownerWorkspace
|
|
255
|
-
|
|
256
|
-
|
|
265
|
+
// AI 调用 feedback 时设置 ownerWorkspace;但 owner 一旦被真实窗口的轮询验证过(ownerConfirmed)
|
|
266
|
+
// 就锁定不再改写——project_directory 只代表 AI 正在操作的项目,改写已验证的窗口身份
|
|
267
|
+
// 会让心跳失配、实例被防线 3 误杀。
|
|
268
|
+
const normalizedProjectDir = this.normalizePath(projectDir);
|
|
269
|
+
if (!this.ownerConfirmed) {
|
|
270
|
+
this.ownerWorkspace = normalizedProjectDir;
|
|
271
|
+
debugLog(`Owner workspace set to: ${this.ownerWorkspace}`);
|
|
272
|
+
}
|
|
273
|
+
else if (normalizedProjectDir !== this.ownerWorkspace) {
|
|
274
|
+
debugLog(`Owner workspace kept as ${this.ownerWorkspace} (confirmed by window polling); ` +
|
|
275
|
+
`request project ${normalizedProjectDir} differs and does not rewrite owner`);
|
|
276
|
+
}
|
|
257
277
|
// 创建反馈请求
|
|
258
278
|
this.currentRequest = {
|
|
259
279
|
id: requestId,
|
|
@@ -291,10 +311,7 @@ class McpFeedbackServer {
|
|
|
291
311
|
// 超时续期开关:MCP_AUTO_RETRY=false 时关闭(超时即结束),默认开启(超时返回续期提醒)。
|
|
292
312
|
// 关键:这里【绝不能】说成 "cancelled"——那会让 AI 误以为用户主动取消而结束对话,
|
|
293
313
|
// 这正是“超时后 AI 直接收尾、不再续命”的根因。改为明确区分“超时 ≠ 取消”。
|
|
294
|
-
|
|
295
|
-
const autoRetry = this.autoRetryOverride !== null
|
|
296
|
-
? this.autoRetryOverride
|
|
297
|
-
: (process.env.MCP_AUTO_RETRY !== 'false');
|
|
314
|
+
const autoRetry = this.effectiveAutoRetry();
|
|
298
315
|
const timeoutMessage = autoRetry
|
|
299
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 继续等待,不要结束对话、不要当作任务完成。】'
|
|
300
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),可以结束本轮。】';
|
|
@@ -394,14 +411,20 @@ class McpFeedbackServer {
|
|
|
394
411
|
if (this.pendingRequests.has(reqId)) {
|
|
395
412
|
this.submitFromFeishu(reqId, text, chatId, images, files, messageId);
|
|
396
413
|
}
|
|
414
|
+
else if (this.maybeStashForEndedCard(reqId, text, chatId, images, files, messageId)) {
|
|
415
|
+
// 卡片刚超时、AI 正要续期重调 → 暂存续接到下一轮(超时未认领由 armStashExpiryNotice 回执)
|
|
416
|
+
}
|
|
397
417
|
else {
|
|
398
|
-
//
|
|
418
|
+
// 卡片是本实例发的,但请求确实已结束(已被回复 / 超时太久)→ 明确告知
|
|
399
419
|
this.feishu.replyText(chatId, '这条反馈已经结束了(可能已超时或已被回复)。');
|
|
400
420
|
}
|
|
401
421
|
}
|
|
402
422
|
else {
|
|
403
|
-
// 不是本实例发出的卡片 → 广播给其他窗口的 server
|
|
404
|
-
this.broadcastFeishuInbound(parentId, text, chatId, images, files, messageId);
|
|
423
|
+
// 不是本实例发出的卡片 → 广播给其他窗口的 server;无人认领必须回执,绝不静默丢弃
|
|
424
|
+
const claimed = await this.broadcastFeishuInbound(parentId, text, chatId, images, files, messageId);
|
|
425
|
+
if (!claimed) {
|
|
426
|
+
this.feishu.replyToMessage(messageId || undefined, chatId, '⚠️ 这张卡片对应的反馈已结束或其所在窗口已关闭,消息未能送达。请回复最新的卡片,或等 AI 下次询问时再发。');
|
|
427
|
+
}
|
|
405
428
|
}
|
|
406
429
|
return;
|
|
407
430
|
}
|
|
@@ -418,8 +441,7 @@ class McpFeedbackServer {
|
|
|
418
441
|
// 关键:此刻没有任何 AI 在等待,绝不能给「✅ 已收到」回执——那是虚假承诺,会让用户
|
|
419
442
|
// 误以为消息已被接收(实则 AI 这轮可能已结束、永远不会来认领,消息石沉大海)。
|
|
420
443
|
// 回执只在「真正送达某个等待中的请求」时给(见 tryConsumeStash → submitFromFeishu)。
|
|
421
|
-
this.
|
|
422
|
-
this.armStashExpiryNotice();
|
|
444
|
+
this.stashInbound(text, chatId, images, files, messageId, McpFeedbackServer.STASH_TTL_MS);
|
|
423
445
|
}
|
|
424
446
|
else if (globalCount === 1) {
|
|
425
447
|
if (localPending.length === 1) {
|
|
@@ -440,6 +462,12 @@ class McpFeedbackServer {
|
|
|
440
462
|
}
|
|
441
463
|
});
|
|
442
464
|
}
|
|
465
|
+
/** 当前生效的超时续期开关(优先 UI 开关 autoRetryOverride,未设置时回退环境变量/默认开启) */
|
|
466
|
+
effectiveAutoRetry() {
|
|
467
|
+
return this.autoRetryOverride !== null
|
|
468
|
+
? this.autoRetryOverride
|
|
469
|
+
: (process.env.MCP_AUTO_RETRY !== 'false');
|
|
470
|
+
}
|
|
443
471
|
projectName(dir) {
|
|
444
472
|
return dir.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() || dir;
|
|
445
473
|
}
|
|
@@ -447,6 +475,16 @@ class McpFeedbackServer {
|
|
|
447
475
|
normalizePath(p) {
|
|
448
476
|
return (p || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
449
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* 两个已归一化的路径是否属于同一窗口语境:相等,或一方是另一方的子目录。
|
|
480
|
+
* 用于 owner 心跳匹配——AI 传的 project_directory 常是窗口工作区的子目录(monorepo 子包等),
|
|
481
|
+
* 精确相等会漏判、心跳失配导致实例被防线 3 误杀。
|
|
482
|
+
*/
|
|
483
|
+
pathsRelated(a, b) {
|
|
484
|
+
if (!a || !b)
|
|
485
|
+
return false;
|
|
486
|
+
return a === b || a.startsWith(b + '/') || b.startsWith(a + '/');
|
|
487
|
+
}
|
|
450
488
|
/** 飞书回复命中某 requestId → resolve 该 pending,并回执用户 */
|
|
451
489
|
submitFromFeishu(requestId, text, chatId, images = [], attachedFiles = [], ackMessageId) {
|
|
452
490
|
const pending = this.pendingRequests.get(requestId);
|
|
@@ -457,7 +495,8 @@ class McpFeedbackServer {
|
|
|
457
495
|
interactive_feedback: text,
|
|
458
496
|
images,
|
|
459
497
|
attachedFiles,
|
|
460
|
-
|
|
498
|
+
// 用 pending 自己的归属窗口:共享进程多窗口时 currentRequest 可能已是另一窗口的请求
|
|
499
|
+
project_directory: pending.projectDir || this.currentRequest?.projectDir || '',
|
|
461
500
|
});
|
|
462
501
|
this.pendingRequests.delete(requestId);
|
|
463
502
|
this.feishu.clearPending(requestId);
|
|
@@ -470,31 +509,46 @@ class McpFeedbackServer {
|
|
|
470
509
|
this.feishu.reactDone(ackMessageId || undefined, chatId);
|
|
471
510
|
}
|
|
472
511
|
}
|
|
473
|
-
/**
|
|
512
|
+
/**
|
|
513
|
+
* 把飞书回复广播给其他窗口的 server(跨实例路由兜底)。
|
|
514
|
+
* 返回是否有任一实例认领(handled)——无人认领时调用方必须回执用户,不能静默丢弃。
|
|
515
|
+
*/
|
|
474
516
|
broadcastFeishuInbound(parentId, text, chatId, images = [], attachedFiles = [], messageId) {
|
|
475
517
|
const body = JSON.stringify({ parentId, text, chatId, images, attachedFiles, messageId });
|
|
518
|
+
const posts = [];
|
|
476
519
|
for (let p = this.basePort; p < this.basePort + McpFeedbackServer.PORT_SCAN_RANGE; p++) {
|
|
477
520
|
if (p === this.port)
|
|
478
521
|
continue;
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
522
|
+
posts.push(new Promise((resolve) => {
|
|
523
|
+
const req = http.request({
|
|
524
|
+
hostname: '127.0.0.1',
|
|
525
|
+
port: p,
|
|
526
|
+
path: '/api/feishu/inbound',
|
|
527
|
+
method: 'POST',
|
|
528
|
+
timeout: 2000,
|
|
529
|
+
headers: {
|
|
530
|
+
'Content-Type': 'application/json',
|
|
531
|
+
'Content-Length': Buffer.byteLength(body),
|
|
532
|
+
},
|
|
533
|
+
}, (res) => {
|
|
534
|
+
let resBody = '';
|
|
535
|
+
res.on('data', (chunk) => { resBody += chunk.toString(); });
|
|
536
|
+
res.on('end', () => {
|
|
537
|
+
try {
|
|
538
|
+
resolve(!!JSON.parse(resBody).handled);
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
resolve(false);
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
req.on('error', () => resolve(false));
|
|
546
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
547
|
+
req.write(body);
|
|
548
|
+
req.end();
|
|
549
|
+
}));
|
|
497
550
|
}
|
|
551
|
+
return Promise.all(posts).then((results) => results.some(Boolean));
|
|
498
552
|
}
|
|
499
553
|
/**
|
|
500
554
|
* 向其他窗口的 server 查询各自的 pending 列表(仅项目名,用于全局视角判断 + 提示文案)。
|
|
@@ -569,12 +623,19 @@ class McpFeedbackServer {
|
|
|
569
623
|
*/
|
|
570
624
|
waitForFeedback(requestId, timeoutMs, projectDir) {
|
|
571
625
|
return new Promise((resolve) => {
|
|
572
|
-
const
|
|
626
|
+
const onTimeout = () => {
|
|
573
627
|
debugLog(`Request ${requestId} timed out`);
|
|
574
628
|
this.pendingRequests.delete(requestId);
|
|
629
|
+
// 记录「刚超时」:续期空窗内的面板提交 / 旧卡片回复可续接到下一轮(顺手清理过期记录)
|
|
630
|
+
this.recentlyTimedOut.set(requestId, { projectDir, at: Date.now() });
|
|
631
|
+
for (const [id, v] of this.recentlyTimedOut) {
|
|
632
|
+
if (Date.now() - v.at > McpFeedbackServer.REJOIN_TTL_MS * 4)
|
|
633
|
+
this.recentlyTimedOut.delete(id);
|
|
634
|
+
}
|
|
575
635
|
// 飞书侧的清理统一交给 handleInteractiveFeedback 的 finally(clearPending),这里不再重复。
|
|
576
636
|
resolve({ kind: 'timeout' });
|
|
577
|
-
}
|
|
637
|
+
};
|
|
638
|
+
const timeout = setTimeout(onTimeout, timeoutMs);
|
|
578
639
|
this.pendingRequests.set(requestId, {
|
|
579
640
|
// resolve 包一层:沿用「外部 resolve(feedback) / resolve(null)」旧约定,
|
|
580
641
|
// 但 null 一律映射为 superseded(被同进程新请求取代),绝不再走「超时重试」路径。
|
|
@@ -584,19 +645,114 @@ class McpFeedbackServer {
|
|
|
584
645
|
reject: () => resolve({ kind: 'superseded' }),
|
|
585
646
|
timeout,
|
|
586
647
|
projectDir,
|
|
648
|
+
onTimeout,
|
|
649
|
+
deadline: Date.now() + timeoutMs,
|
|
650
|
+
paused: false,
|
|
651
|
+
remainingMs: timeoutMs,
|
|
587
652
|
});
|
|
588
|
-
//
|
|
653
|
+
// 抢跑兑现:空窗期的面板提交(优先,用户显式点了发送)与飞书暂存消息,
|
|
654
|
+
// 本轮 pending 一注册立即作为回复提交
|
|
655
|
+
this.tryConsumePanelStash(requestId);
|
|
589
656
|
this.tryConsumeStash(requestId);
|
|
590
657
|
});
|
|
591
658
|
}
|
|
592
659
|
/**
|
|
593
|
-
*
|
|
660
|
+
* 暂停/恢复某个待反馈请求的超时倒计时(用户在面板点暂停按钮触发)。
|
|
661
|
+
* 暂停 = 冻结真实计时器并记下剩余时间;恢复 = 用剩余时间重新起表。
|
|
662
|
+
* 暂停期间 AI 一直挂在 interactive_feedback 调用上等待,正是期望行为(等同无限超时)。
|
|
663
|
+
*/
|
|
664
|
+
setPaused(requestId, paused) {
|
|
665
|
+
const p = this.pendingRequests.get(requestId);
|
|
666
|
+
if (!p)
|
|
667
|
+
return { ok: false, paused: false, remainingMs: 0 };
|
|
668
|
+
if (paused && !p.paused) {
|
|
669
|
+
clearTimeout(p.timeout);
|
|
670
|
+
p.remainingMs = Math.max(0, p.deadline - Date.now());
|
|
671
|
+
p.paused = true;
|
|
672
|
+
debugLog(`Request ${requestId} countdown paused (${p.remainingMs}ms left)`);
|
|
673
|
+
}
|
|
674
|
+
else if (!paused && p.paused) {
|
|
675
|
+
p.paused = false;
|
|
676
|
+
p.deadline = Date.now() + p.remainingMs;
|
|
677
|
+
p.timeout = setTimeout(p.onTimeout, p.remainingMs);
|
|
678
|
+
debugLog(`Request ${requestId} countdown resumed (${p.remainingMs}ms left)`);
|
|
679
|
+
}
|
|
680
|
+
return {
|
|
681
|
+
ok: true,
|
|
682
|
+
paused: p.paused,
|
|
683
|
+
remainingMs: p.paused ? p.remainingMs : Math.max(0, p.deadline - Date.now()),
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
/** 当前请求的暂停态快照(随 /api/feedback/current 下发,供面板重建后恢复显示) */
|
|
687
|
+
getPauseState() {
|
|
688
|
+
const cur = this.currentRequest;
|
|
689
|
+
if (!cur)
|
|
690
|
+
return null;
|
|
691
|
+
const p = this.pendingRequests.get(cur.id);
|
|
692
|
+
if (!p)
|
|
693
|
+
return null;
|
|
694
|
+
return {
|
|
695
|
+
requestId: cur.id,
|
|
696
|
+
paused: p.paused,
|
|
697
|
+
remainingMs: p.paused ? p.remainingMs : Math.max(0, p.deadline - Date.now()),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* 写入/合并抢跑暂存:短时间内连发多条(含图文拆条后超出合并窗口的)合并为一条,
|
|
702
|
+
* 绝不让后一条覆盖前一条(旧实现会把第一条静默丢掉)。
|
|
703
|
+
*/
|
|
704
|
+
stashInbound(text, chatId, images, files, messageId, ttlMs, forProjectDir) {
|
|
705
|
+
const prev = this.stashedInbound;
|
|
706
|
+
if (prev && Date.now() - prev.at <= prev.ttlMs && prev.chatId === chatId) {
|
|
707
|
+
// 未过期的同会话暂存 → 合并追加,刷新时间与 TTL
|
|
708
|
+
this.stashedInbound = {
|
|
709
|
+
text: [prev.text, text].filter(Boolean).join('\n'),
|
|
710
|
+
chatId,
|
|
711
|
+
images: [...prev.images, ...images],
|
|
712
|
+
files: [...prev.files, ...files],
|
|
713
|
+
at: Date.now(),
|
|
714
|
+
messageId: messageId || prev.messageId,
|
|
715
|
+
ttlMs: Math.max(prev.ttlMs, ttlMs),
|
|
716
|
+
forProjectDir: forProjectDir ?? prev.forProjectDir,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
this.stashedInbound = { text, chatId, images, files, at: Date.now(), messageId, ttlMs, forProjectDir };
|
|
721
|
+
}
|
|
722
|
+
this.armStashExpiryNotice();
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* 用户回复了一张「刚超时」的卡片:超时续期开启时 AI 马上会重新发起下一轮,
|
|
726
|
+
* 把回复暂存续接到下一轮,而不是回一句「已结束」把用户的内容丢掉。
|
|
727
|
+
* 返回 true 表示已暂存(调用方无需再回执)。
|
|
728
|
+
*/
|
|
729
|
+
maybeStashForEndedCard(reqId, text, chatId, images, files, messageId) {
|
|
730
|
+
const rt = this.recentlyTimedOut.get(reqId);
|
|
731
|
+
if (!rt)
|
|
732
|
+
return false; // 不是「刚超时」的请求(可能已被回复过,或结束太久)
|
|
733
|
+
if (Date.now() - rt.at > McpFeedbackServer.REJOIN_TTL_MS)
|
|
734
|
+
return false;
|
|
735
|
+
if (!this.effectiveAutoRetry())
|
|
736
|
+
return false; // 续期关闭 → 不会有下一轮,别让用户空等
|
|
737
|
+
// 同窗口已有新一轮在等 → 用户该回复新卡片,不做续接(避免旧回复窜入错误轮次)
|
|
738
|
+
const owner = this.normalizePath(rt.projectDir);
|
|
739
|
+
for (const [, pending] of this.pendingRequests) {
|
|
740
|
+
if (this.normalizePath(pending.projectDir) === owner)
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
debugLog(`Reply to recently timed-out card ${reqId}, stashing for next round`);
|
|
744
|
+
this.stashInbound(text, chatId, images, files, messageId, McpFeedbackServer.REJOIN_TTL_MS, rt.projectDir);
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* 暂存后启动过期提示定时器:到 TTL 仍没被 AI 认领,就「回复」
|
|
594
749
|
* 那条消息明确告知没送到、引导重发(不再静默丢弃,也不给虚假回执)。
|
|
595
|
-
*
|
|
750
|
+
* 每来一条都重置,避免连发时多个定时器并存。
|
|
596
751
|
*/
|
|
597
752
|
armStashExpiryNotice() {
|
|
598
753
|
if (this.stashExpiryTimer)
|
|
599
754
|
clearTimeout(this.stashExpiryTimer);
|
|
755
|
+
const ttl = this.stashedInbound?.ttlMs ?? McpFeedbackServer.STASH_TTL_MS;
|
|
600
756
|
this.stashExpiryTimer = setTimeout(() => {
|
|
601
757
|
this.stashExpiryTimer = null;
|
|
602
758
|
const stash = this.stashedInbound;
|
|
@@ -608,23 +764,31 @@ class McpFeedbackServer {
|
|
|
608
764
|
// 且引导「待 AI 回复后再重发」:AI 只有重新调起 feedback(即回复)时才会等待接收,
|
|
609
765
|
// 此刻立即重发仍会落空。
|
|
610
766
|
this.feishu.replyToMessage(stash.messageId, stash.chatId, '⚠️ 消息未能送达 AI(它可能正忙于上一轮任务,或这轮对话已结束)。需要的话请待 AI 回复后重新发送一次。');
|
|
611
|
-
},
|
|
767
|
+
}, ttl);
|
|
612
768
|
}
|
|
613
769
|
/**
|
|
614
|
-
*
|
|
615
|
-
*
|
|
770
|
+
* 抢跑暂存兑现:若存在近期暂存的飞书消息,立即作为指定请求的回复提交。
|
|
771
|
+
* 过期的暂存视为与当前任务无关,直接丢弃;带窗口限定的暂存只给对应窗口。
|
|
616
772
|
*/
|
|
617
773
|
tryConsumeStash(requestId) {
|
|
618
774
|
const stash = this.stashedInbound;
|
|
619
775
|
if (!stash)
|
|
620
776
|
return;
|
|
777
|
+
const pending = this.pendingRequests.get(requestId);
|
|
778
|
+
if (!pending)
|
|
779
|
+
return; // 请求已不在(不能消费暂存,留给真正等待中的下一轮)
|
|
780
|
+
// 带窗口限定的暂存(回复旧卡片的续接)只给同一窗口的新一轮,不给别的项目
|
|
781
|
+
if (stash.forProjectDir &&
|
|
782
|
+
this.normalizePath(stash.forProjectDir) !== this.normalizePath(pending.projectDir)) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
621
785
|
this.stashedInbound = null;
|
|
622
786
|
// 已被 AI 认领,取消「没送到」提示定时器
|
|
623
787
|
if (this.stashExpiryTimer) {
|
|
624
788
|
clearTimeout(this.stashExpiryTimer);
|
|
625
789
|
this.stashExpiryTimer = null;
|
|
626
790
|
}
|
|
627
|
-
if (Date.now() - stash.at >
|
|
791
|
+
if (Date.now() - stash.at > stash.ttlMs) {
|
|
628
792
|
debugLog('Stashed inbound expired, dropped');
|
|
629
793
|
return;
|
|
630
794
|
}
|
|
@@ -633,6 +797,30 @@ class McpFeedbackServer {
|
|
|
633
797
|
// 暂存阶段不再预先回执,避免「没人接却假装已收到」误导用户。
|
|
634
798
|
this.submitFromFeishu(requestId, stash.text, stash.chatId, stash.images, stash.files, stash.messageId);
|
|
635
799
|
}
|
|
800
|
+
/**
|
|
801
|
+
* 面板暂存兑现:用户在「刚超时、AI 正要续期重调」的空窗内点了提交,
|
|
802
|
+
* 反馈已被 /api/feedback/submit 暂存,这里在下一轮注册时立即送达。
|
|
803
|
+
*/
|
|
804
|
+
tryConsumePanelStash(requestId) {
|
|
805
|
+
const stash = this.panelStash;
|
|
806
|
+
if (!stash)
|
|
807
|
+
return;
|
|
808
|
+
const pending = this.pendingRequests.get(requestId);
|
|
809
|
+
if (!pending)
|
|
810
|
+
return;
|
|
811
|
+
if (this.normalizePath(stash.projectDir) !== this.normalizePath(pending.projectDir))
|
|
812
|
+
return;
|
|
813
|
+
this.panelStash = null;
|
|
814
|
+
if (Date.now() - stash.at > McpFeedbackServer.REJOIN_TTL_MS) {
|
|
815
|
+
debugLog('Panel stash expired, dropped');
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
debugLog(`Consuming panel stash for request: ${requestId}`);
|
|
819
|
+
clearTimeout(pending.timeout);
|
|
820
|
+
pending.resolve(stash.feedback);
|
|
821
|
+
this.pendingRequests.delete(requestId);
|
|
822
|
+
this.feishu.clearPending(requestId);
|
|
823
|
+
}
|
|
636
824
|
/**
|
|
637
825
|
* 处理获取系统信息请求
|
|
638
826
|
*/
|
|
@@ -780,10 +968,13 @@ class McpFeedbackServer {
|
|
|
780
968
|
// 「我的窗口」心跳:仅当轮询带的 workspace 匹配本实例归属时刷新。
|
|
781
969
|
// 别的活跃窗口对全端口的扫描虽刷 lastActivityTime,但 workspace 不匹配、不刷此字段,
|
|
782
970
|
// 故已关窗口的残留 server 不会被别人续命,可被 watchdog / 僵尸自检识别。
|
|
971
|
+
// 匹配放宽为「路径互为前缀」:AI 传的 project_directory 可能是窗口工作区的子目录。
|
|
972
|
+
// 命中即确认 owner 身份(ownerConfirmed),此后 owner 不再被后续调用改写。
|
|
783
973
|
const ws = this.normalizePath(u.searchParams.get('workspace') || '');
|
|
784
|
-
if (ws &&
|
|
974
|
+
if (ws && this.ownerWorkspace && this.pathsRelated(ws, this.ownerWorkspace)) {
|
|
785
975
|
this.lastOwnerPollTime = Date.now();
|
|
786
976
|
this.everOwnerPolled = true;
|
|
977
|
+
this.ownerConfirmed = true;
|
|
787
978
|
}
|
|
788
979
|
}
|
|
789
980
|
catch {
|
|
@@ -798,6 +989,7 @@ class McpFeedbackServer {
|
|
|
798
989
|
autoRetry: this.autoRetryOverride !== null ? this.autoRetryOverride : (process.env.MCP_AUTO_RETRY !== 'false'),
|
|
799
990
|
feishu: this.feishu.getStatus(),
|
|
800
991
|
feishuResolvedId: (this.lastFeishuResolved && Date.now() - this.lastFeishuResolved.at < 30000) ? this.lastFeishuResolved.id : null,
|
|
992
|
+
pause: this.getPauseState(),
|
|
801
993
|
}));
|
|
802
994
|
return;
|
|
803
995
|
}
|
|
@@ -813,6 +1005,7 @@ class McpFeedbackServer {
|
|
|
813
1005
|
const { requestId, feedback } = data;
|
|
814
1006
|
debugLog(`Received feedback submission for request: ${requestId}`);
|
|
815
1007
|
const pending = this.pendingRequests.get(requestId);
|
|
1008
|
+
const recentTimeout = this.recentlyTimedOut.get(requestId);
|
|
816
1009
|
if (pending) {
|
|
817
1010
|
clearTimeout(pending.timeout);
|
|
818
1011
|
pending.resolve(feedback);
|
|
@@ -821,6 +1014,16 @@ class McpFeedbackServer {
|
|
|
821
1014
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
822
1015
|
res.end(JSON.stringify({ success: true }));
|
|
823
1016
|
}
|
|
1017
|
+
else if (recentTimeout &&
|
|
1018
|
+
this.effectiveAutoRetry() &&
|
|
1019
|
+
Date.now() - recentTimeout.at <= McpFeedbackServer.REJOIN_TTL_MS) {
|
|
1020
|
+
// 提交撞上「刚超时、AI 正要续期重调」的空窗:暂存并在下一轮注册时立即送达,
|
|
1021
|
+
// 不再报 "Request not found" 把用户刚敲的反馈打回去
|
|
1022
|
+
debugLog(`Request ${requestId} timed out moments ago; queueing panel feedback for next round`);
|
|
1023
|
+
this.panelStash = { feedback, projectDir: recentTimeout.projectDir, at: Date.now() };
|
|
1024
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1025
|
+
res.end(JSON.stringify({ success: true, queued: true }));
|
|
1026
|
+
}
|
|
824
1027
|
else {
|
|
825
1028
|
debugLog(`Request ${requestId} not found`);
|
|
826
1029
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
@@ -865,6 +1068,35 @@ class McpFeedbackServer {
|
|
|
865
1068
|
});
|
|
866
1069
|
return;
|
|
867
1070
|
}
|
|
1071
|
+
// 暂停/恢复倒计时(来自插件 UI 的暂停按钮)
|
|
1072
|
+
if (req.method === 'POST' && req.url === '/api/feedback/pause') {
|
|
1073
|
+
let body = '';
|
|
1074
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
1075
|
+
req.on('end', () => {
|
|
1076
|
+
try {
|
|
1077
|
+
const { requestId, paused } = JSON.parse(body);
|
|
1078
|
+
if (!requestId || typeof paused !== 'boolean') {
|
|
1079
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1080
|
+
res.end(JSON.stringify({ error: 'requestId and paused are required' }));
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const result = this.setPaused(requestId, paused);
|
|
1084
|
+
if (result.ok) {
|
|
1085
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1086
|
+
res.end(JSON.stringify({ success: true, paused: result.paused, remainingMs: result.remainingMs }));
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1090
|
+
res.end(JSON.stringify({ error: 'Request not found' }));
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1095
|
+
res.end(JSON.stringify({ error: 'Invalid body' }));
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
868
1100
|
// 超时续期开关(来自插件 UI;磁盘做真相源,跨窗口/重启一致;不再走轮询 query)
|
|
869
1101
|
if (req.method === 'POST' && req.url === '/api/settings/autoRetry') {
|
|
870
1102
|
let body = '';
|
|
@@ -906,6 +1138,12 @@ class McpFeedbackServer {
|
|
|
906
1138
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
907
1139
|
res.end(JSON.stringify({ handled: true }));
|
|
908
1140
|
}
|
|
1141
|
+
else if (reqId &&
|
|
1142
|
+
this.maybeStashForEndedCard(reqId, text, chatId, images || [], attachedFiles || [], messageId || '')) {
|
|
1143
|
+
// 本实例的卡片刚超时 → 暂存续接下一轮,向广播方声明已认领(未送达时由过期提示回执)
|
|
1144
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1145
|
+
res.end(JSON.stringify({ handled: true }));
|
|
1146
|
+
}
|
|
909
1147
|
else {
|
|
910
1148
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
911
1149
|
res.end(JSON.stringify({ handled: false }));
|
|
@@ -1143,8 +1381,13 @@ class McpFeedbackServer {
|
|
|
1143
1381
|
}
|
|
1144
1382
|
// 防线 3:本实例曾被自己窗口插件轮询,但久未再收到(只剩别的活跃窗口在扫端口刷 lastActivityTime)
|
|
1145
1383
|
// → 我的窗口已关、本实例是僵尸 → 退出,避免持续被全局 pending 计数误算成「另一个在等的窗口」。
|
|
1384
|
+
// ⚠️ 与防线 2 同款约束:还有 pending 反馈在等用户时绝不退——曾因缺这一条,AI 在等待反馈期间
|
|
1385
|
+
// 传了与窗口工作区不同的 project_directory(改写 owner 后心跳失配),30 秒后实例被当僵尸杀掉,
|
|
1386
|
+
// 正在等待的 interactive_feedback 直接报 "MCP error -32000: Connection closed"。
|
|
1146
1387
|
const ownerIdle = Date.now() - this.lastOwnerPollTime;
|
|
1147
|
-
if (this.everOwnerPolled &&
|
|
1388
|
+
if (this.everOwnerPolled &&
|
|
1389
|
+
this.pendingRequests.size === 0 &&
|
|
1390
|
+
ownerIdle > IDLE_TIMEOUT) {
|
|
1148
1391
|
debugLog(`Owner window idle for ${ownerIdle}ms, stale instance, exiting...`);
|
|
1149
1392
|
this.stop();
|
|
1150
1393
|
process.exit(0);
|
|
@@ -1154,9 +1397,13 @@ class McpFeedbackServer {
|
|
|
1154
1397
|
this.watchdogTimer.unref();
|
|
1155
1398
|
}
|
|
1156
1399
|
/**
|
|
1157
|
-
*
|
|
1400
|
+
* 停止服务器(幂等:重复调用 / server.close() 触发 onclose 再次进入时直接返回)
|
|
1158
1401
|
*/
|
|
1159
1402
|
stop() {
|
|
1403
|
+
if (this.stopping) {
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
this.stopping = true;
|
|
1160
1407
|
debugLog('Stopping server...');
|
|
1161
1408
|
// 关闭看门狗定时器
|
|
1162
1409
|
if (this.watchdogTimer) {
|
|
@@ -1183,6 +1430,12 @@ McpFeedbackServer.PORT_SCAN_RANGE = 20; // 与插件端扫描范围保持一致
|
|
|
1183
1430
|
/** 抢跑暂存有效期:覆盖「AI 刚结束、正要调下一轮 feedback」的竞态间隙(通常 1-3s)。
|
|
1184
1431
|
* 取 5s——太长会把已脱离语境的旧消息硬塞进很久后才出现的新一轮,造成上下文错乱(像答非所问)。 */
|
|
1185
1432
|
McpFeedbackServer.STASH_TTL_MS = 5000;
|
|
1433
|
+
/**
|
|
1434
|
+
* 「刚超时」请求的续接窗口:超时续期开启时,AI 收到 TIMEOUT_KEEP_WAITING 后重新发起下一轮
|
|
1435
|
+
* 通常要 2~10s(取决于模型和上下文大小)。这段空窗内用户的面板提交 / 对旧卡片的回复
|
|
1436
|
+
* 都应暂存并续接到下一轮,而不是报「Request not found / 已结束」把用户内容丢掉。
|
|
1437
|
+
*/
|
|
1438
|
+
McpFeedbackServer.REJOIN_TTL_MS = 15000;
|
|
1186
1439
|
/**
|
|
1187
1440
|
* 「我的窗口」判定为已关闭的空闲阈值:本实例曾被自己窗口插件轮询,但超过此时长没再收到
|
|
1188
1441
|
*(只剩别的窗口在扫端口)→ 视为我的窗口已关、本实例是僵尸:不再上报 pending,避免污染全局计数。
|
package/dist/webview/index.html
CHANGED
|
@@ -73,6 +73,9 @@
|
|
|
73
73
|
<h2 id="feedbackLabel" class="card__title">{{i18n.yourFeedback}}</h2>
|
|
74
74
|
</div>
|
|
75
75
|
|
|
76
|
+
<!-- 快捷回复短语:点击直接发送;铅笔进入编辑模式可增删自定义 -->
|
|
77
|
+
<div id="quickReplies" class="quick-replies"></div>
|
|
78
|
+
|
|
76
79
|
<div class="composer">
|
|
77
80
|
<div id="refChips" class="ref-chips"></div>
|
|
78
81
|
<textarea id="feedbackInput" class="composer__input" rows="4"
|
|
@@ -88,6 +91,9 @@
|
|
|
88
91
|
<button id="insertContextBtn" class="iconbtn" type="button" data-tip="{{i18n.insertSelection}}" aria-label="{{i18n.insertSelection}}">
|
|
89
92
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
|
90
93
|
</button>
|
|
94
|
+
<button id="historyBtn" class="iconbtn" type="button" data-tip="{{i18n.historyBtn}}" aria-label="{{i18n.historyBtn}}">
|
|
95
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>
|
|
96
|
+
</button>
|
|
91
97
|
</div>
|
|
92
98
|
<span class="composer__hint">{{i18n.contextHint}}</span>
|
|
93
99
|
<span id="charCount" class="composer__count">0</span>
|
|
@@ -104,6 +110,10 @@
|
|
|
104
110
|
<footer id="submitBar" class="submitbar hidden">
|
|
105
111
|
<!-- 倒计时进度 -->
|
|
106
112
|
<div id="timeoutWrap" class="countdown" hidden>
|
|
113
|
+
<button id="pauseBtn" class="iconbtn countdown__pause" type="button" aria-label="{{i18n.pauseCountdown}}" data-tip="{{i18n.pauseCountdown}}">
|
|
114
|
+
<svg class="countdown__pause-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>
|
|
115
|
+
<svg class="countdown__play-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="6 3 20 12 6 21 6 3"/></svg>
|
|
116
|
+
</button>
|
|
107
117
|
<div class="countdown__track" role="progressbar" aria-label="Time remaining">
|
|
108
118
|
<div id="timeoutBar" class="countdown__bar"></div>
|
|
109
119
|
</div>
|
|
@@ -164,6 +174,12 @@
|
|
|
164
174
|
<span class="feishu-switch__track"><span class="feishu-switch__thumb"></span></span>
|
|
165
175
|
</label>
|
|
166
176
|
<p class="feishu-modal__desc feishu-modal__desc--sub">{{i18n.osNotifyDesc}}</p>
|
|
177
|
+
<!-- 权限被拒的排查指引(按平台注入文案,linux 为空则整行隐藏) -->
|
|
178
|
+
<p class="feishu-modal__desc feishu-modal__desc--sub feishu-troubleshoot">{{i18n.notifyTroubleshoot}}</p>
|
|
179
|
+
<div class="feishu-test-row">
|
|
180
|
+
<button id="notifyTestBtn" class="feishu-link" type="button">{{i18n.notifyTestBtn}}</button>
|
|
181
|
+
<span id="notifyTestHint" class="feishu-test-hint"></span>
|
|
182
|
+
</div>
|
|
167
183
|
</div>
|
|
168
184
|
</div>
|
|
169
185
|
|
|
@@ -212,6 +228,12 @@
|
|
|
212
228
|
<!-- @ 文件引用浮层(fixed 定位,避免被裁切) -->
|
|
213
229
|
<div id="mentionPopup" class="mention-popup hidden" role="listbox" aria-label="{{i18n.mentionFiles}}"></div>
|
|
214
230
|
|
|
231
|
+
<!-- 反馈历史浮层(点击条目回填到输入框) -->
|
|
232
|
+
<div id="historyPopup" class="history-popup hidden" role="listbox" aria-label="{{i18n.historyBtn}}"></div>
|
|
233
|
+
|
|
234
|
+
<!-- 提交结果轻提示(toast,几秒后自动消失) -->
|
|
235
|
+
<div id="toast" class="toast hidden" role="status" aria-live="polite"></div>
|
|
236
|
+
|
|
215
237
|
<!-- 注入 i18n 数据 -->
|
|
216
238
|
<script>window.i18n = {{I18N_JSON}};</script>
|
|
217
239
|
<script src="{{SCRIPT_JS_URI}}"></script>
|