cursor-feedback 2.0.5 → 2.1.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 +12 -0
- package/dist/extension.js +139 -11
- 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 +253 -38
- 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 的请求:供插件端精确区分「飞书回复」与「超时」,
|
|
@@ -291,10 +295,7 @@ class McpFeedbackServer {
|
|
|
291
295
|
// 超时续期开关:MCP_AUTO_RETRY=false 时关闭(超时即结束),默认开启(超时返回续期提醒)。
|
|
292
296
|
// 关键:这里【绝不能】说成 "cancelled"——那会让 AI 误以为用户主动取消而结束对话,
|
|
293
297
|
// 这正是“超时后 AI 直接收尾、不再续命”的根因。改为明确区分“超时 ≠ 取消”。
|
|
294
|
-
|
|
295
|
-
const autoRetry = this.autoRetryOverride !== null
|
|
296
|
-
? this.autoRetryOverride
|
|
297
|
-
: (process.env.MCP_AUTO_RETRY !== 'false');
|
|
298
|
+
const autoRetry = this.effectiveAutoRetry();
|
|
298
299
|
const timeoutMessage = autoRetry
|
|
299
300
|
? '[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
301
|
: '[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 +395,20 @@ class McpFeedbackServer {
|
|
|
394
395
|
if (this.pendingRequests.has(reqId)) {
|
|
395
396
|
this.submitFromFeishu(reqId, text, chatId, images, files, messageId);
|
|
396
397
|
}
|
|
398
|
+
else if (this.maybeStashForEndedCard(reqId, text, chatId, images, files, messageId)) {
|
|
399
|
+
// 卡片刚超时、AI 正要续期重调 → 暂存续接到下一轮(超时未认领由 armStashExpiryNotice 回执)
|
|
400
|
+
}
|
|
397
401
|
else {
|
|
398
|
-
//
|
|
402
|
+
// 卡片是本实例发的,但请求确实已结束(已被回复 / 超时太久)→ 明确告知
|
|
399
403
|
this.feishu.replyText(chatId, '这条反馈已经结束了(可能已超时或已被回复)。');
|
|
400
404
|
}
|
|
401
405
|
}
|
|
402
406
|
else {
|
|
403
|
-
// 不是本实例发出的卡片 → 广播给其他窗口的 server
|
|
404
|
-
this.broadcastFeishuInbound(parentId, text, chatId, images, files, messageId);
|
|
407
|
+
// 不是本实例发出的卡片 → 广播给其他窗口的 server;无人认领必须回执,绝不静默丢弃
|
|
408
|
+
const claimed = await this.broadcastFeishuInbound(parentId, text, chatId, images, files, messageId);
|
|
409
|
+
if (!claimed) {
|
|
410
|
+
this.feishu.replyToMessage(messageId || undefined, chatId, '⚠️ 这张卡片对应的反馈已结束或其所在窗口已关闭,消息未能送达。请回复最新的卡片,或等 AI 下次询问时再发。');
|
|
411
|
+
}
|
|
405
412
|
}
|
|
406
413
|
return;
|
|
407
414
|
}
|
|
@@ -418,8 +425,7 @@ class McpFeedbackServer {
|
|
|
418
425
|
// 关键:此刻没有任何 AI 在等待,绝不能给「✅ 已收到」回执——那是虚假承诺,会让用户
|
|
419
426
|
// 误以为消息已被接收(实则 AI 这轮可能已结束、永远不会来认领,消息石沉大海)。
|
|
420
427
|
// 回执只在「真正送达某个等待中的请求」时给(见 tryConsumeStash → submitFromFeishu)。
|
|
421
|
-
this.
|
|
422
|
-
this.armStashExpiryNotice();
|
|
428
|
+
this.stashInbound(text, chatId, images, files, messageId, McpFeedbackServer.STASH_TTL_MS);
|
|
423
429
|
}
|
|
424
430
|
else if (globalCount === 1) {
|
|
425
431
|
if (localPending.length === 1) {
|
|
@@ -440,6 +446,12 @@ class McpFeedbackServer {
|
|
|
440
446
|
}
|
|
441
447
|
});
|
|
442
448
|
}
|
|
449
|
+
/** 当前生效的超时续期开关(优先 UI 开关 autoRetryOverride,未设置时回退环境变量/默认开启) */
|
|
450
|
+
effectiveAutoRetry() {
|
|
451
|
+
return this.autoRetryOverride !== null
|
|
452
|
+
? this.autoRetryOverride
|
|
453
|
+
: (process.env.MCP_AUTO_RETRY !== 'false');
|
|
454
|
+
}
|
|
443
455
|
projectName(dir) {
|
|
444
456
|
return dir.replace(/\\/g, '/').replace(/\/+$/, '').split('/').pop() || dir;
|
|
445
457
|
}
|
|
@@ -457,7 +469,8 @@ class McpFeedbackServer {
|
|
|
457
469
|
interactive_feedback: text,
|
|
458
470
|
images,
|
|
459
471
|
attachedFiles,
|
|
460
|
-
|
|
472
|
+
// 用 pending 自己的归属窗口:共享进程多窗口时 currentRequest 可能已是另一窗口的请求
|
|
473
|
+
project_directory: pending.projectDir || this.currentRequest?.projectDir || '',
|
|
461
474
|
});
|
|
462
475
|
this.pendingRequests.delete(requestId);
|
|
463
476
|
this.feishu.clearPending(requestId);
|
|
@@ -470,31 +483,46 @@ class McpFeedbackServer {
|
|
|
470
483
|
this.feishu.reactDone(ackMessageId || undefined, chatId);
|
|
471
484
|
}
|
|
472
485
|
}
|
|
473
|
-
/**
|
|
486
|
+
/**
|
|
487
|
+
* 把飞书回复广播给其他窗口的 server(跨实例路由兜底)。
|
|
488
|
+
* 返回是否有任一实例认领(handled)——无人认领时调用方必须回执用户,不能静默丢弃。
|
|
489
|
+
*/
|
|
474
490
|
broadcastFeishuInbound(parentId, text, chatId, images = [], attachedFiles = [], messageId) {
|
|
475
491
|
const body = JSON.stringify({ parentId, text, chatId, images, attachedFiles, messageId });
|
|
492
|
+
const posts = [];
|
|
476
493
|
for (let p = this.basePort; p < this.basePort + McpFeedbackServer.PORT_SCAN_RANGE; p++) {
|
|
477
494
|
if (p === this.port)
|
|
478
495
|
continue;
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
496
|
+
posts.push(new Promise((resolve) => {
|
|
497
|
+
const req = http.request({
|
|
498
|
+
hostname: '127.0.0.1',
|
|
499
|
+
port: p,
|
|
500
|
+
path: '/api/feishu/inbound',
|
|
501
|
+
method: 'POST',
|
|
502
|
+
timeout: 2000,
|
|
503
|
+
headers: {
|
|
504
|
+
'Content-Type': 'application/json',
|
|
505
|
+
'Content-Length': Buffer.byteLength(body),
|
|
506
|
+
},
|
|
507
|
+
}, (res) => {
|
|
508
|
+
let resBody = '';
|
|
509
|
+
res.on('data', (chunk) => { resBody += chunk.toString(); });
|
|
510
|
+
res.on('end', () => {
|
|
511
|
+
try {
|
|
512
|
+
resolve(!!JSON.parse(resBody).handled);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
resolve(false);
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
req.on('error', () => resolve(false));
|
|
520
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
521
|
+
req.write(body);
|
|
522
|
+
req.end();
|
|
523
|
+
}));
|
|
497
524
|
}
|
|
525
|
+
return Promise.all(posts).then((results) => results.some(Boolean));
|
|
498
526
|
}
|
|
499
527
|
/**
|
|
500
528
|
* 向其他窗口的 server 查询各自的 pending 列表(仅项目名,用于全局视角判断 + 提示文案)。
|
|
@@ -569,12 +597,19 @@ class McpFeedbackServer {
|
|
|
569
597
|
*/
|
|
570
598
|
waitForFeedback(requestId, timeoutMs, projectDir) {
|
|
571
599
|
return new Promise((resolve) => {
|
|
572
|
-
const
|
|
600
|
+
const onTimeout = () => {
|
|
573
601
|
debugLog(`Request ${requestId} timed out`);
|
|
574
602
|
this.pendingRequests.delete(requestId);
|
|
603
|
+
// 记录「刚超时」:续期空窗内的面板提交 / 旧卡片回复可续接到下一轮(顺手清理过期记录)
|
|
604
|
+
this.recentlyTimedOut.set(requestId, { projectDir, at: Date.now() });
|
|
605
|
+
for (const [id, v] of this.recentlyTimedOut) {
|
|
606
|
+
if (Date.now() - v.at > McpFeedbackServer.REJOIN_TTL_MS * 4)
|
|
607
|
+
this.recentlyTimedOut.delete(id);
|
|
608
|
+
}
|
|
575
609
|
// 飞书侧的清理统一交给 handleInteractiveFeedback 的 finally(clearPending),这里不再重复。
|
|
576
610
|
resolve({ kind: 'timeout' });
|
|
577
|
-
}
|
|
611
|
+
};
|
|
612
|
+
const timeout = setTimeout(onTimeout, timeoutMs);
|
|
578
613
|
this.pendingRequests.set(requestId, {
|
|
579
614
|
// resolve 包一层:沿用「外部 resolve(feedback) / resolve(null)」旧约定,
|
|
580
615
|
// 但 null 一律映射为 superseded(被同进程新请求取代),绝不再走「超时重试」路径。
|
|
@@ -584,19 +619,114 @@ class McpFeedbackServer {
|
|
|
584
619
|
reject: () => resolve({ kind: 'superseded' }),
|
|
585
620
|
timeout,
|
|
586
621
|
projectDir,
|
|
622
|
+
onTimeout,
|
|
623
|
+
deadline: Date.now() + timeoutMs,
|
|
624
|
+
paused: false,
|
|
625
|
+
remainingMs: timeoutMs,
|
|
587
626
|
});
|
|
588
|
-
//
|
|
627
|
+
// 抢跑兑现:空窗期的面板提交(优先,用户显式点了发送)与飞书暂存消息,
|
|
628
|
+
// 本轮 pending 一注册立即作为回复提交
|
|
629
|
+
this.tryConsumePanelStash(requestId);
|
|
589
630
|
this.tryConsumeStash(requestId);
|
|
590
631
|
});
|
|
591
632
|
}
|
|
592
633
|
/**
|
|
593
|
-
*
|
|
634
|
+
* 暂停/恢复某个待反馈请求的超时倒计时(用户在面板点暂停按钮触发)。
|
|
635
|
+
* 暂停 = 冻结真实计时器并记下剩余时间;恢复 = 用剩余时间重新起表。
|
|
636
|
+
* 暂停期间 AI 一直挂在 interactive_feedback 调用上等待,正是期望行为(等同无限超时)。
|
|
637
|
+
*/
|
|
638
|
+
setPaused(requestId, paused) {
|
|
639
|
+
const p = this.pendingRequests.get(requestId);
|
|
640
|
+
if (!p)
|
|
641
|
+
return { ok: false, paused: false, remainingMs: 0 };
|
|
642
|
+
if (paused && !p.paused) {
|
|
643
|
+
clearTimeout(p.timeout);
|
|
644
|
+
p.remainingMs = Math.max(0, p.deadline - Date.now());
|
|
645
|
+
p.paused = true;
|
|
646
|
+
debugLog(`Request ${requestId} countdown paused (${p.remainingMs}ms left)`);
|
|
647
|
+
}
|
|
648
|
+
else if (!paused && p.paused) {
|
|
649
|
+
p.paused = false;
|
|
650
|
+
p.deadline = Date.now() + p.remainingMs;
|
|
651
|
+
p.timeout = setTimeout(p.onTimeout, p.remainingMs);
|
|
652
|
+
debugLog(`Request ${requestId} countdown resumed (${p.remainingMs}ms left)`);
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
ok: true,
|
|
656
|
+
paused: p.paused,
|
|
657
|
+
remainingMs: p.paused ? p.remainingMs : Math.max(0, p.deadline - Date.now()),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
/** 当前请求的暂停态快照(随 /api/feedback/current 下发,供面板重建后恢复显示) */
|
|
661
|
+
getPauseState() {
|
|
662
|
+
const cur = this.currentRequest;
|
|
663
|
+
if (!cur)
|
|
664
|
+
return null;
|
|
665
|
+
const p = this.pendingRequests.get(cur.id);
|
|
666
|
+
if (!p)
|
|
667
|
+
return null;
|
|
668
|
+
return {
|
|
669
|
+
requestId: cur.id,
|
|
670
|
+
paused: p.paused,
|
|
671
|
+
remainingMs: p.paused ? p.remainingMs : Math.max(0, p.deadline - Date.now()),
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* 写入/合并抢跑暂存:短时间内连发多条(含图文拆条后超出合并窗口的)合并为一条,
|
|
676
|
+
* 绝不让后一条覆盖前一条(旧实现会把第一条静默丢掉)。
|
|
677
|
+
*/
|
|
678
|
+
stashInbound(text, chatId, images, files, messageId, ttlMs, forProjectDir) {
|
|
679
|
+
const prev = this.stashedInbound;
|
|
680
|
+
if (prev && Date.now() - prev.at <= prev.ttlMs && prev.chatId === chatId) {
|
|
681
|
+
// 未过期的同会话暂存 → 合并追加,刷新时间与 TTL
|
|
682
|
+
this.stashedInbound = {
|
|
683
|
+
text: [prev.text, text].filter(Boolean).join('\n'),
|
|
684
|
+
chatId,
|
|
685
|
+
images: [...prev.images, ...images],
|
|
686
|
+
files: [...prev.files, ...files],
|
|
687
|
+
at: Date.now(),
|
|
688
|
+
messageId: messageId || prev.messageId,
|
|
689
|
+
ttlMs: Math.max(prev.ttlMs, ttlMs),
|
|
690
|
+
forProjectDir: forProjectDir ?? prev.forProjectDir,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
this.stashedInbound = { text, chatId, images, files, at: Date.now(), messageId, ttlMs, forProjectDir };
|
|
695
|
+
}
|
|
696
|
+
this.armStashExpiryNotice();
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* 用户回复了一张「刚超时」的卡片:超时续期开启时 AI 马上会重新发起下一轮,
|
|
700
|
+
* 把回复暂存续接到下一轮,而不是回一句「已结束」把用户的内容丢掉。
|
|
701
|
+
* 返回 true 表示已暂存(调用方无需再回执)。
|
|
702
|
+
*/
|
|
703
|
+
maybeStashForEndedCard(reqId, text, chatId, images, files, messageId) {
|
|
704
|
+
const rt = this.recentlyTimedOut.get(reqId);
|
|
705
|
+
if (!rt)
|
|
706
|
+
return false; // 不是「刚超时」的请求(可能已被回复过,或结束太久)
|
|
707
|
+
if (Date.now() - rt.at > McpFeedbackServer.REJOIN_TTL_MS)
|
|
708
|
+
return false;
|
|
709
|
+
if (!this.effectiveAutoRetry())
|
|
710
|
+
return false; // 续期关闭 → 不会有下一轮,别让用户空等
|
|
711
|
+
// 同窗口已有新一轮在等 → 用户该回复新卡片,不做续接(避免旧回复窜入错误轮次)
|
|
712
|
+
const owner = this.normalizePath(rt.projectDir);
|
|
713
|
+
for (const [, pending] of this.pendingRequests) {
|
|
714
|
+
if (this.normalizePath(pending.projectDir) === owner)
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
debugLog(`Reply to recently timed-out card ${reqId}, stashing for next round`);
|
|
718
|
+
this.stashInbound(text, chatId, images, files, messageId, McpFeedbackServer.REJOIN_TTL_MS, rt.projectDir);
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* 暂存后启动过期提示定时器:到 TTL 仍没被 AI 认领,就「回复」
|
|
594
723
|
* 那条消息明确告知没送到、引导重发(不再静默丢弃,也不给虚假回执)。
|
|
595
|
-
*
|
|
724
|
+
* 每来一条都重置,避免连发时多个定时器并存。
|
|
596
725
|
*/
|
|
597
726
|
armStashExpiryNotice() {
|
|
598
727
|
if (this.stashExpiryTimer)
|
|
599
728
|
clearTimeout(this.stashExpiryTimer);
|
|
729
|
+
const ttl = this.stashedInbound?.ttlMs ?? McpFeedbackServer.STASH_TTL_MS;
|
|
600
730
|
this.stashExpiryTimer = setTimeout(() => {
|
|
601
731
|
this.stashExpiryTimer = null;
|
|
602
732
|
const stash = this.stashedInbound;
|
|
@@ -608,23 +738,31 @@ class McpFeedbackServer {
|
|
|
608
738
|
// 且引导「待 AI 回复后再重发」:AI 只有重新调起 feedback(即回复)时才会等待接收,
|
|
609
739
|
// 此刻立即重发仍会落空。
|
|
610
740
|
this.feishu.replyToMessage(stash.messageId, stash.chatId, '⚠️ 消息未能送达 AI(它可能正忙于上一轮任务,或这轮对话已结束)。需要的话请待 AI 回复后重新发送一次。');
|
|
611
|
-
},
|
|
741
|
+
}, ttl);
|
|
612
742
|
}
|
|
613
743
|
/**
|
|
614
|
-
*
|
|
615
|
-
*
|
|
744
|
+
* 抢跑暂存兑现:若存在近期暂存的飞书消息,立即作为指定请求的回复提交。
|
|
745
|
+
* 过期的暂存视为与当前任务无关,直接丢弃;带窗口限定的暂存只给对应窗口。
|
|
616
746
|
*/
|
|
617
747
|
tryConsumeStash(requestId) {
|
|
618
748
|
const stash = this.stashedInbound;
|
|
619
749
|
if (!stash)
|
|
620
750
|
return;
|
|
751
|
+
const pending = this.pendingRequests.get(requestId);
|
|
752
|
+
if (!pending)
|
|
753
|
+
return; // 请求已不在(不能消费暂存,留给真正等待中的下一轮)
|
|
754
|
+
// 带窗口限定的暂存(回复旧卡片的续接)只给同一窗口的新一轮,不给别的项目
|
|
755
|
+
if (stash.forProjectDir &&
|
|
756
|
+
this.normalizePath(stash.forProjectDir) !== this.normalizePath(pending.projectDir)) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
621
759
|
this.stashedInbound = null;
|
|
622
760
|
// 已被 AI 认领,取消「没送到」提示定时器
|
|
623
761
|
if (this.stashExpiryTimer) {
|
|
624
762
|
clearTimeout(this.stashExpiryTimer);
|
|
625
763
|
this.stashExpiryTimer = null;
|
|
626
764
|
}
|
|
627
|
-
if (Date.now() - stash.at >
|
|
765
|
+
if (Date.now() - stash.at > stash.ttlMs) {
|
|
628
766
|
debugLog('Stashed inbound expired, dropped');
|
|
629
767
|
return;
|
|
630
768
|
}
|
|
@@ -633,6 +771,30 @@ class McpFeedbackServer {
|
|
|
633
771
|
// 暂存阶段不再预先回执,避免「没人接却假装已收到」误导用户。
|
|
634
772
|
this.submitFromFeishu(requestId, stash.text, stash.chatId, stash.images, stash.files, stash.messageId);
|
|
635
773
|
}
|
|
774
|
+
/**
|
|
775
|
+
* 面板暂存兑现:用户在「刚超时、AI 正要续期重调」的空窗内点了提交,
|
|
776
|
+
* 反馈已被 /api/feedback/submit 暂存,这里在下一轮注册时立即送达。
|
|
777
|
+
*/
|
|
778
|
+
tryConsumePanelStash(requestId) {
|
|
779
|
+
const stash = this.panelStash;
|
|
780
|
+
if (!stash)
|
|
781
|
+
return;
|
|
782
|
+
const pending = this.pendingRequests.get(requestId);
|
|
783
|
+
if (!pending)
|
|
784
|
+
return;
|
|
785
|
+
if (this.normalizePath(stash.projectDir) !== this.normalizePath(pending.projectDir))
|
|
786
|
+
return;
|
|
787
|
+
this.panelStash = null;
|
|
788
|
+
if (Date.now() - stash.at > McpFeedbackServer.REJOIN_TTL_MS) {
|
|
789
|
+
debugLog('Panel stash expired, dropped');
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
debugLog(`Consuming panel stash for request: ${requestId}`);
|
|
793
|
+
clearTimeout(pending.timeout);
|
|
794
|
+
pending.resolve(stash.feedback);
|
|
795
|
+
this.pendingRequests.delete(requestId);
|
|
796
|
+
this.feishu.clearPending(requestId);
|
|
797
|
+
}
|
|
636
798
|
/**
|
|
637
799
|
* 处理获取系统信息请求
|
|
638
800
|
*/
|
|
@@ -798,6 +960,7 @@ class McpFeedbackServer {
|
|
|
798
960
|
autoRetry: this.autoRetryOverride !== null ? this.autoRetryOverride : (process.env.MCP_AUTO_RETRY !== 'false'),
|
|
799
961
|
feishu: this.feishu.getStatus(),
|
|
800
962
|
feishuResolvedId: (this.lastFeishuResolved && Date.now() - this.lastFeishuResolved.at < 30000) ? this.lastFeishuResolved.id : null,
|
|
963
|
+
pause: this.getPauseState(),
|
|
801
964
|
}));
|
|
802
965
|
return;
|
|
803
966
|
}
|
|
@@ -813,6 +976,7 @@ class McpFeedbackServer {
|
|
|
813
976
|
const { requestId, feedback } = data;
|
|
814
977
|
debugLog(`Received feedback submission for request: ${requestId}`);
|
|
815
978
|
const pending = this.pendingRequests.get(requestId);
|
|
979
|
+
const recentTimeout = this.recentlyTimedOut.get(requestId);
|
|
816
980
|
if (pending) {
|
|
817
981
|
clearTimeout(pending.timeout);
|
|
818
982
|
pending.resolve(feedback);
|
|
@@ -821,6 +985,16 @@ class McpFeedbackServer {
|
|
|
821
985
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
822
986
|
res.end(JSON.stringify({ success: true }));
|
|
823
987
|
}
|
|
988
|
+
else if (recentTimeout &&
|
|
989
|
+
this.effectiveAutoRetry() &&
|
|
990
|
+
Date.now() - recentTimeout.at <= McpFeedbackServer.REJOIN_TTL_MS) {
|
|
991
|
+
// 提交撞上「刚超时、AI 正要续期重调」的空窗:暂存并在下一轮注册时立即送达,
|
|
992
|
+
// 不再报 "Request not found" 把用户刚敲的反馈打回去
|
|
993
|
+
debugLog(`Request ${requestId} timed out moments ago; queueing panel feedback for next round`);
|
|
994
|
+
this.panelStash = { feedback, projectDir: recentTimeout.projectDir, at: Date.now() };
|
|
995
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
996
|
+
res.end(JSON.stringify({ success: true, queued: true }));
|
|
997
|
+
}
|
|
824
998
|
else {
|
|
825
999
|
debugLog(`Request ${requestId} not found`);
|
|
826
1000
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
@@ -865,6 +1039,35 @@ class McpFeedbackServer {
|
|
|
865
1039
|
});
|
|
866
1040
|
return;
|
|
867
1041
|
}
|
|
1042
|
+
// 暂停/恢复倒计时(来自插件 UI 的暂停按钮)
|
|
1043
|
+
if (req.method === 'POST' && req.url === '/api/feedback/pause') {
|
|
1044
|
+
let body = '';
|
|
1045
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
1046
|
+
req.on('end', () => {
|
|
1047
|
+
try {
|
|
1048
|
+
const { requestId, paused } = JSON.parse(body);
|
|
1049
|
+
if (!requestId || typeof paused !== 'boolean') {
|
|
1050
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1051
|
+
res.end(JSON.stringify({ error: 'requestId and paused are required' }));
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const result = this.setPaused(requestId, paused);
|
|
1055
|
+
if (result.ok) {
|
|
1056
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1057
|
+
res.end(JSON.stringify({ success: true, paused: result.paused, remainingMs: result.remainingMs }));
|
|
1058
|
+
}
|
|
1059
|
+
else {
|
|
1060
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
1061
|
+
res.end(JSON.stringify({ error: 'Request not found' }));
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1066
|
+
res.end(JSON.stringify({ error: 'Invalid body' }));
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
868
1071
|
// 超时续期开关(来自插件 UI;磁盘做真相源,跨窗口/重启一致;不再走轮询 query)
|
|
869
1072
|
if (req.method === 'POST' && req.url === '/api/settings/autoRetry') {
|
|
870
1073
|
let body = '';
|
|
@@ -906,6 +1109,12 @@ class McpFeedbackServer {
|
|
|
906
1109
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
907
1110
|
res.end(JSON.stringify({ handled: true }));
|
|
908
1111
|
}
|
|
1112
|
+
else if (reqId &&
|
|
1113
|
+
this.maybeStashForEndedCard(reqId, text, chatId, images || [], attachedFiles || [], messageId || '')) {
|
|
1114
|
+
// 本实例的卡片刚超时 → 暂存续接下一轮,向广播方声明已认领(未送达时由过期提示回执)
|
|
1115
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1116
|
+
res.end(JSON.stringify({ handled: true }));
|
|
1117
|
+
}
|
|
909
1118
|
else {
|
|
910
1119
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
911
1120
|
res.end(JSON.stringify({ handled: false }));
|
|
@@ -1183,6 +1392,12 @@ McpFeedbackServer.PORT_SCAN_RANGE = 20; // 与插件端扫描范围保持一致
|
|
|
1183
1392
|
/** 抢跑暂存有效期:覆盖「AI 刚结束、正要调下一轮 feedback」的竞态间隙(通常 1-3s)。
|
|
1184
1393
|
* 取 5s——太长会把已脱离语境的旧消息硬塞进很久后才出现的新一轮,造成上下文错乱(像答非所问)。 */
|
|
1185
1394
|
McpFeedbackServer.STASH_TTL_MS = 5000;
|
|
1395
|
+
/**
|
|
1396
|
+
* 「刚超时」请求的续接窗口:超时续期开启时,AI 收到 TIMEOUT_KEEP_WAITING 后重新发起下一轮
|
|
1397
|
+
* 通常要 2~10s(取决于模型和上下文大小)。这段空窗内用户的面板提交 / 对旧卡片的回复
|
|
1398
|
+
* 都应暂存并续接到下一轮,而不是报「Request not found / 已结束」把用户内容丢掉。
|
|
1399
|
+
*/
|
|
1400
|
+
McpFeedbackServer.REJOIN_TTL_MS = 15000;
|
|
1186
1401
|
/**
|
|
1187
1402
|
* 「我的窗口」判定为已关闭的空闲阈值:本实例曾被自己窗口插件轮询,但超过此时长没再收到
|
|
1188
1403
|
*(只剩别的窗口在扫端口)→ 视为我的窗口已关、本实例是僵尸:不再上报 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>
|