cursor-feedback 2.2.0 → 2.3.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 +14 -0
- package/dist/extension.js +228 -18
- package/dist/feishu.js +113 -0
- package/dist/i18n/en.json +23 -3
- package/dist/i18n/index.js +23 -1
- package/dist/i18n/zh-CN.json +23 -3
- package/dist/mcp-server.js +223 -34
- package/dist/webview/index.html +40 -12
- package/dist/webview/script.js +409 -28
- package/dist/webview/styles.css +215 -19
- package/package.json +4 -1
package/dist/mcp-server.js
CHANGED
|
@@ -72,6 +72,16 @@ function debugLog(message) {
|
|
|
72
72
|
const timestamp = new Date().toISOString();
|
|
73
73
|
console.error(`[${timestamp}] ${message}`);
|
|
74
74
|
}
|
|
75
|
+
// 包版本真相源:package.json(dist/mcp-server.js 的上一级目录)。读不到时兜底 0.0.0。
|
|
76
|
+
const PKG_VERSION = (() => {
|
|
77
|
+
try {
|
|
78
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
79
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return '0.0.0';
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
75
85
|
/**
|
|
76
86
|
* MCP Feedback Server
|
|
77
87
|
*/
|
|
@@ -95,11 +105,14 @@ class McpFeedbackServer {
|
|
|
95
105
|
/** 面板在超时空窗内提交的反馈:暂存到下一轮 pending 注册时立即兑现 */
|
|
96
106
|
this.panelStash = null;
|
|
97
107
|
/**
|
|
98
|
-
* 忙时消息队列:AI
|
|
108
|
+
* 忙时消息队列:AI 正在干活(该项目空间没有等待中的反馈请求)时用户发来的消息
|
|
99
109
|
* 不再丢弃,而是按项目空间排队;等 AI 下一轮调 interactive_feedback 时合并送达,
|
|
100
|
-
*
|
|
110
|
+
* 并附「任务期间追加」提示头。飞书与面板消息共用同一个队列(按到达顺序),
|
|
111
|
+
* 飞书侧开关见 FeishuBridge.queueWhenBusy(面板 / FEISHU_QUEUE);面板排队恒可用。
|
|
101
112
|
*/
|
|
102
113
|
this.queuedInbound = [];
|
|
114
|
+
/** 队列项 id 自增序号(配合时间戳保证进程内唯一) */
|
|
115
|
+
this.queueSeq = 0;
|
|
103
116
|
/** 暂存过期提示定时器:到点仍未被认领则「回复」那条消息告知没送到,避免静默丢弃 */
|
|
104
117
|
this.stashExpiryTimer = null;
|
|
105
118
|
// 最近一次被飞书回复 resolve 的请求:供插件端精确区分「飞书回复」与「超时」,
|
|
@@ -135,11 +148,16 @@ class McpFeedbackServer {
|
|
|
135
148
|
// stop() 防重入:server.close() 会触发 transport.onclose → 回调里又调 stop(),
|
|
136
149
|
// 无标志位会无限递归直至 "Maximum call stack size exceeded"(日志曾刷出数万条 Stopping server...)
|
|
137
150
|
this.stopping = false;
|
|
151
|
+
/**
|
|
152
|
+
* 已结束请求的结果缓存(wire id → 当时的结果):供「迟到的重复投递」精确重放。
|
|
153
|
+
* 容量与时效都收紧(feedback 结果可能含 base64 图片,不能无限囤积)。
|
|
154
|
+
*/
|
|
155
|
+
this.settledByWire = new Map();
|
|
138
156
|
this.port = port;
|
|
139
157
|
this.basePort = port;
|
|
140
158
|
this.server = new index_js_1.Server({
|
|
141
159
|
name: 'cursor-feedback-server',
|
|
142
|
-
version:
|
|
160
|
+
version: PKG_VERSION,
|
|
143
161
|
}, {
|
|
144
162
|
capabilities: {
|
|
145
163
|
tools: {},
|
|
@@ -211,14 +229,17 @@ class McpFeedbackServer {
|
|
|
211
229
|
};
|
|
212
230
|
});
|
|
213
231
|
// 处理工具调用
|
|
214
|
-
this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
232
|
+
this.server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => {
|
|
215
233
|
// AI 发起调用,刷新活动时间,避免被 watchdog 误判为闲置
|
|
216
234
|
this.lastActivityTime = Date.now();
|
|
217
235
|
const { name, arguments: args } = request.params;
|
|
236
|
+
// JSON-RPC 请求 id(同一连接内唯一):客户端把同一次 tool call 重复投递时若复用
|
|
237
|
+
// 同一 id,可据此做精确去重 / 结果重放(比 summary 内容启发式可靠)。
|
|
238
|
+
const wireKey = extra?.requestId !== undefined ? String(extra.requestId) : undefined;
|
|
218
239
|
try {
|
|
219
240
|
switch (name) {
|
|
220
241
|
case 'interactive_feedback':
|
|
221
|
-
return await this.handleInteractiveFeedback(args);
|
|
242
|
+
return await this.handleInteractiveFeedback(args, wireKey);
|
|
222
243
|
case 'get_system_info':
|
|
223
244
|
return this.handleGetSystemInfo();
|
|
224
245
|
default:
|
|
@@ -239,8 +260,9 @@ class McpFeedbackServer {
|
|
|
239
260
|
}
|
|
240
261
|
/**
|
|
241
262
|
* 处理交互式反馈请求
|
|
263
|
+
* @param wireKey 本次调用的 JSON-RPC 请求 id(用于重复投递的精确识别与结果重放)
|
|
242
264
|
*/
|
|
243
|
-
async handleInteractiveFeedback(args) {
|
|
265
|
+
async handleInteractiveFeedback(args, wireKey) {
|
|
244
266
|
// 参数校验:project_directory 是必填项
|
|
245
267
|
if (!args?.project_directory) {
|
|
246
268
|
const receivedParams = JSON.stringify(args || {});
|
|
@@ -263,15 +285,29 @@ class McpFeedbackServer {
|
|
|
263
285
|
const envTimeout = process.env.MCP_FEEDBACK_TIMEOUT ? parseInt(process.env.MCP_FEEDBACK_TIMEOUT, 10) : null;
|
|
264
286
|
const timeout = envTimeout || args?.timeout || 300;
|
|
265
287
|
const requestId = this.generateRequestId();
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
288
|
+
// 重复投递识别:客户端/传输层会把同一次 tool call 原样重复投递(实测某一时刻把进程内
|
|
289
|
+
// 所有 in-flight 调用各重投一遍,距原投递可晚至 3~8 分钟,无固定上限)。重复投递必须
|
|
290
|
+
// join 原等待共享结果,绝不能当新一轮:不发新卡、不顶旧请求——顶了会让原调用收到
|
|
291
|
+
// SUPERSEDED(AI 误以为被新会话取代而提前收尾),且重投开出的幽灵卡片没人认领,
|
|
292
|
+
// 用户回复它只会石沉大海。
|
|
293
|
+
const dup = this.findDuplicatePending(projectDir, summary, timeout, wireKey);
|
|
270
294
|
if (dup) {
|
|
271
|
-
debugLog(`Duplicate delivery detected for request ${dup.id}; joining its wait instead of superseding`);
|
|
295
|
+
debugLog(`Duplicate delivery detected (matched by ${dup.via}) for request ${dup.id}; joining its wait instead of superseding`);
|
|
272
296
|
const outcome = await new Promise((res) => dup.waiters.push(res));
|
|
297
|
+
this.rememberSettledWire(wireKey, outcome, dup.id);
|
|
273
298
|
return this.outcomeToResult(outcome, dup.id);
|
|
274
299
|
}
|
|
300
|
+
// 迟到的重复投递(原请求已结束):同 wire id 精确命中已结束请求 → 原样重放当时的结果。
|
|
301
|
+
// 绝不能开新一轮:原调用早已返回、没人会认领新卡,用户回复只会丢失。
|
|
302
|
+
// 只按 wire id 匹配、绝不按 summary——AI 超时续期的新一轮常复用同一句 summary,
|
|
303
|
+
// 按内容重放旧超时会造成「新调用秒收超时 → 立即重调 → 又秒收」的快速空转。
|
|
304
|
+
if (wireKey) {
|
|
305
|
+
const settled = this.settledByWire.get(wireKey);
|
|
306
|
+
if (settled) {
|
|
307
|
+
debugLog(`Late duplicate delivery (wire id ${wireKey}) for settled request ${settled.requestId}; replaying its outcome`);
|
|
308
|
+
return this.outcomeToResult(settled.outcome, settled.requestId);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
275
311
|
// 作废上一轮残留的「僵尸」请求:单实例单窗口同时只应有一个活跃反馈请求。
|
|
276
312
|
// 旧请求多半是对话被压缩 / 客户端取消后还卡在 await 的残留(要等 timeout 才自然结束),
|
|
277
313
|
// 不清理会让 pendingCount 虚高、全局视角误判「多个窗口在等」
|
|
@@ -311,7 +347,8 @@ class McpFeedbackServer {
|
|
|
311
347
|
}
|
|
312
348
|
try {
|
|
313
349
|
// 等待用户反馈
|
|
314
|
-
const outcome = await this.waitForFeedback(request, timeout * 1000);
|
|
350
|
+
const outcome = await this.waitForFeedback(request, timeout * 1000, wireKey);
|
|
351
|
+
this.rememberSettledWire(wireKey, outcome, requestId);
|
|
315
352
|
return this.outcomeToResult(outcome, requestId);
|
|
316
353
|
}
|
|
317
354
|
catch (error) {
|
|
@@ -593,19 +630,49 @@ class McpFeedbackServer {
|
|
|
593
630
|
this.pendingRequests.delete(reqId);
|
|
594
631
|
}
|
|
595
632
|
}
|
|
596
|
-
|
|
633
|
+
/**
|
|
634
|
+
* 查找可 join 的「重复投递」等待,两级匹配:
|
|
635
|
+
* 1) wire id 精确匹配:同一条 JSON-RPC 请求(同 id)还在等待 → 必是重复投递;
|
|
636
|
+
* 2) 内容启发式:同窗口 + 同 summary + 同 timeout,且原请求仍在 pending(覆盖客户端
|
|
637
|
+
* 重投时换了新 wire id 的情况)。
|
|
638
|
+
* 内容启发式不设时间窗——旧实现的 90s 窗口被实测击穿(重投可晚至 3~8 分钟,无固定上限)。
|
|
639
|
+
* 不设窗口是安全的:pending 仍存活 = 原调用尚未返回 = 同一会话不可能已合法开启新一轮;
|
|
640
|
+
* 不同会话在同一窗口撞出一字不差的 summary + timeout 概率可忽略,即便撞上,
|
|
641
|
+
* join(双方共享同一份回复)也远比 supersede(悄悄掐掉别人的等待)安全。
|
|
642
|
+
*/
|
|
643
|
+
findDuplicatePending(projectDir, summary, timeout, wireKey) {
|
|
597
644
|
const owner = this.normalizePath(projectDir);
|
|
598
645
|
for (const [id, pending] of this.pendingRequests) {
|
|
646
|
+
if (wireKey !== undefined && pending.wireId === wireKey) {
|
|
647
|
+
return { id, via: 'wire id', waiters: pending.waiters };
|
|
648
|
+
}
|
|
599
649
|
if (this.normalizePath(pending.projectDir) !== owner)
|
|
600
650
|
continue;
|
|
601
651
|
if (pending.request.summary !== summary)
|
|
602
652
|
continue;
|
|
603
|
-
if (
|
|
653
|
+
if (pending.request.timeout !== timeout)
|
|
604
654
|
continue;
|
|
605
|
-
return { id, waiters: pending.waiters };
|
|
655
|
+
return { id, via: 'content', waiters: pending.waiters };
|
|
606
656
|
}
|
|
607
657
|
return null;
|
|
608
658
|
}
|
|
659
|
+
rememberSettledWire(wireKey, outcome, requestId) {
|
|
660
|
+
if (!wireKey)
|
|
661
|
+
return;
|
|
662
|
+
const now = Date.now();
|
|
663
|
+
this.settledByWire.set(wireKey, { outcome, requestId, at: now });
|
|
664
|
+
for (const [k, v] of this.settledByWire) {
|
|
665
|
+
if (now - v.at > McpFeedbackServer.SETTLED_WIRE_TTL_MS)
|
|
666
|
+
this.settledByWire.delete(k);
|
|
667
|
+
}
|
|
668
|
+
// Map 按插入序迭代,超限时先淘汰最早的
|
|
669
|
+
while (this.settledByWire.size > McpFeedbackServer.SETTLED_WIRE_MAX) {
|
|
670
|
+
const oldest = this.settledByWire.keys().next().value;
|
|
671
|
+
if (oldest === undefined)
|
|
672
|
+
break;
|
|
673
|
+
this.settledByWire.delete(oldest);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
609
676
|
/**
|
|
610
677
|
* 把等待结果翻译成 MCP 工具响应(主等待与重复投递 join 的等待共用同一段收尾语义)。
|
|
611
678
|
*/
|
|
@@ -687,7 +754,7 @@ class McpFeedbackServer {
|
|
|
687
754
|
* 等待用户反馈:注册 pending 并挂起,结果通过 waiters 广播——
|
|
688
755
|
* 首个调用与后续 join 进来的重复投递调用都会收到同一份结果。
|
|
689
756
|
*/
|
|
690
|
-
waitForFeedback(request, timeoutMs) {
|
|
757
|
+
waitForFeedback(request, timeoutMs, wireKey) {
|
|
691
758
|
return new Promise((resolve) => {
|
|
692
759
|
const requestId = request.id;
|
|
693
760
|
const projectDir = request.projectDir;
|
|
@@ -725,6 +792,7 @@ class McpFeedbackServer {
|
|
|
725
792
|
remainingMs: timeoutMs,
|
|
726
793
|
request,
|
|
727
794
|
waiters,
|
|
795
|
+
wireId: wireKey,
|
|
728
796
|
});
|
|
729
797
|
// 抢跑兑现:空窗期的面板提交(优先,用户显式点了发送)与飞书暂存消息,
|
|
730
798
|
// 本轮 pending 一注册立即作为回复提交;最后是忙时队列里排队的追加消息
|
|
@@ -933,13 +1001,43 @@ class McpFeedbackServer {
|
|
|
933
1001
|
}
|
|
934
1002
|
return false;
|
|
935
1003
|
}
|
|
936
|
-
/**
|
|
1004
|
+
/** 队列里是否有属于某项目空间的排队消息(不看渠道开关:面板消息不受飞书队列开关约束) */
|
|
937
1005
|
hasQueuedFor(projectDir) {
|
|
938
|
-
if (!this.feishu.isQueueWhenBusy())
|
|
939
|
-
return false;
|
|
940
1006
|
const owner = this.normalizePath(projectDir);
|
|
941
1007
|
return this.queuedInbound.some((q) => this.pathsRelated(this.normalizePath(q.forProjectDir), owner));
|
|
942
1008
|
}
|
|
1009
|
+
/** 某项目空间的排队消息快照(随 /api/feedback/current 下发给面板展示队列列表) */
|
|
1010
|
+
queuedSnapshotFor(normalizedWs) {
|
|
1011
|
+
if (!normalizedWs)
|
|
1012
|
+
return [];
|
|
1013
|
+
return this.queuedInbound
|
|
1014
|
+
.filter((q) => this.pathsRelated(this.normalizePath(q.forProjectDir), normalizedWs))
|
|
1015
|
+
.map((q) => ({
|
|
1016
|
+
id: q.id,
|
|
1017
|
+
at: q.at,
|
|
1018
|
+
source: q.source,
|
|
1019
|
+
text: q.text,
|
|
1020
|
+
images: q.images.length,
|
|
1021
|
+
files: q.files.length,
|
|
1022
|
+
}));
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* 撤回一条排队消息(面板队列列表的小叉触发)。
|
|
1026
|
+
* 飞书来源的消息同步回执「已撤回」,两边状态一致;面板消息撤掉后列表随轮询消失即回执。
|
|
1027
|
+
* 返回 false = 没找到(可能已被消费或过期),面板下一秒轮询自然对齐,无需特殊处理。
|
|
1028
|
+
*/
|
|
1029
|
+
removeQueuedById(id) {
|
|
1030
|
+
const idx = this.queuedInbound.findIndex((q) => q.id === id);
|
|
1031
|
+
if (idx < 0)
|
|
1032
|
+
return false;
|
|
1033
|
+
const [item] = this.queuedInbound.splice(idx, 1);
|
|
1034
|
+
clearTimeout(item.expiryTimer);
|
|
1035
|
+
debugLog(`Queued message recalled (id=${id}, source=${item.source}, queueSize=${this.queuedInbound.length})`);
|
|
1036
|
+
if (item.source === 'feishu') {
|
|
1037
|
+
this.feishu.replyToMessage(item.messageId || undefined, item.chatId, '🗑️ 这条排队消息已在插件面板被撤回,不会送达 AI。');
|
|
1038
|
+
}
|
|
1039
|
+
return true;
|
|
1040
|
+
}
|
|
943
1041
|
/**
|
|
944
1042
|
* 用户回复了一张「已结束」的卡片(不在超时续接窗口内)→ 忙时排队:
|
|
945
1043
|
* - 该项目已有新一轮在等 → 引导回复最新卡片(避免旧回复窜入错误轮次),视为已处理;
|
|
@@ -962,23 +1060,29 @@ class McpFeedbackServer {
|
|
|
962
1060
|
/**
|
|
963
1061
|
* 消息入队 + 回执用户「已排队」。每条消息带 60 分钟过期兜底:
|
|
964
1062
|
* 到点仍未被 AI 读取(对话可能已结束)→ 引用回复告知未送达,绝不静默丢弃。
|
|
1063
|
+
* 飞书与面板消息共用同一个队列(严格按到达顺序),但回执链路分渠道:
|
|
1064
|
+
* 飞书走引用回复/表情,面板消息没有推送通道,靠轮询下发的队列列表反映在/不在。
|
|
965
1065
|
*/
|
|
966
|
-
enqueueInbound(text, chatId, images, files, messageId, forProjectDir) {
|
|
1066
|
+
enqueueInbound(text, chatId, images, files, messageId, forProjectDir, source = 'feishu') {
|
|
967
1067
|
// 上限保护:挤出最老的一条并告知未送达
|
|
968
1068
|
while (this.queuedInbound.length >= McpFeedbackServer.QUEUE_MAX) {
|
|
969
1069
|
const dropped = this.queuedInbound.shift();
|
|
970
1070
|
if (!dropped)
|
|
971
1071
|
break;
|
|
972
1072
|
clearTimeout(dropped.expiryTimer);
|
|
973
|
-
|
|
1073
|
+
if (dropped.source === 'feishu') {
|
|
1074
|
+
this.feishu.replyToMessage(dropped.messageId || undefined, dropped.chatId, '⚠️ 排队消息过多,这条消息已被挤出队列、未送达 AI,请稍后重发。');
|
|
1075
|
+
}
|
|
974
1076
|
}
|
|
975
1077
|
const item = {
|
|
1078
|
+
id: `q${Date.now()}_${++this.queueSeq}`,
|
|
976
1079
|
text,
|
|
977
1080
|
chatId,
|
|
978
1081
|
images,
|
|
979
1082
|
files,
|
|
980
1083
|
at: Date.now(),
|
|
981
1084
|
messageId,
|
|
1085
|
+
source,
|
|
982
1086
|
forProjectDir,
|
|
983
1087
|
expiryTimer: undefined,
|
|
984
1088
|
};
|
|
@@ -987,12 +1091,16 @@ class McpFeedbackServer {
|
|
|
987
1091
|
if (idx < 0)
|
|
988
1092
|
return; // 已被消费
|
|
989
1093
|
this.queuedInbound.splice(idx, 1);
|
|
990
|
-
|
|
1094
|
+
if (item.source === 'feishu') {
|
|
1095
|
+
this.feishu.replyToMessage(item.messageId || undefined, item.chatId, '⚠️ 这条排队消息等了 60 分钟仍未被 AI 读取(对话可能已结束),未能送达。需要的话请在 AI 下次询问时重发。');
|
|
1096
|
+
}
|
|
991
1097
|
}, McpFeedbackServer.QUEUE_TTL_MS);
|
|
992
1098
|
item.expiryTimer.unref?.();
|
|
993
1099
|
this.queuedInbound.push(item);
|
|
994
|
-
debugLog(`Inbound queued for busy AI (project=${forProjectDir}, queueSize=${this.queuedInbound.length})`);
|
|
995
|
-
|
|
1100
|
+
debugLog(`Inbound queued for busy AI (source=${source}, project=${forProjectDir}, queueSize=${this.queuedInbound.length})`);
|
|
1101
|
+
if (source === 'feishu') {
|
|
1102
|
+
this.feishu.replyToMessage(messageId || undefined, chatId, `🤖 AI 正在工作中,这条消息已排队,将在「${this.projectName(forProjectDir)}」当前任务完成后自动读取。`);
|
|
1103
|
+
}
|
|
996
1104
|
}
|
|
997
1105
|
/**
|
|
998
1106
|
* 队列兑现:新一轮 pending 注册时,把属于该项目空间的所有排队消息合并成一次反馈送达,
|
|
@@ -1022,7 +1130,8 @@ class McpFeedbackServer {
|
|
|
1022
1130
|
});
|
|
1023
1131
|
const text = '[QUEUED_MESSAGES] 以下是用户在你执行上一轮任务期间「追加」发送的消息(当时你正忙,消息已排队暂存)。' +
|
|
1024
1132
|
'注意:这些内容不是对你最新一轮工作摘要的回复,请结合任务上下文阅读并处理;' +
|
|
1025
|
-
'
|
|
1133
|
+
'如与你摘要中的询问冲突,以用户追加内容为准。本次的 summary 用户并没有收到,如有必要可在下次带上。' +
|
|
1134
|
+
'处理完后照常调用 interactive_feedback 继续对话。\n\n' +
|
|
1026
1135
|
`=== 追加消息(${matched.length} 条)===\n` +
|
|
1027
1136
|
lines.join('\n\n');
|
|
1028
1137
|
debugLog(`Consuming ${matched.length} queued message(s) for request: ${requestId}`);
|
|
@@ -1037,9 +1146,12 @@ class McpFeedbackServer {
|
|
|
1037
1146
|
this.feishu.clearPending(requestId);
|
|
1038
1147
|
// 标记为「飞书渠道 resolve」:插件面板据此重置,避免对着已消失的请求提交
|
|
1039
1148
|
this.lastFeishuResolved = { id: requestId, at: Date.now() };
|
|
1040
|
-
//
|
|
1149
|
+
// 送达回执:给每条飞书排队消息补 ✅ 表情(面板消息没有回执通道,
|
|
1150
|
+
// 其送达状态由轮询下发的队列列表体现——消费后列表随即清空)
|
|
1041
1151
|
for (const m of matched) {
|
|
1042
|
-
|
|
1152
|
+
if (m.source === 'feishu') {
|
|
1153
|
+
this.feishu.reactDone(m.messageId || undefined, m.chatId);
|
|
1154
|
+
}
|
|
1043
1155
|
}
|
|
1044
1156
|
}
|
|
1045
1157
|
/** 「我的窗口」是否仍活着(无插件 host 无从判定,视为活跃) */
|
|
@@ -1293,9 +1405,66 @@ class McpFeedbackServer {
|
|
|
1293
1405
|
feishu: this.feishu.getStatus(),
|
|
1294
1406
|
feishuResolvedId: (this.lastFeishuResolved && Date.now() - this.lastFeishuResolved.at < 30000) ? this.lastFeishuResolved.id : null,
|
|
1295
1407
|
pause: this.getPauseStateFor(chosen?.id),
|
|
1408
|
+
// 该窗口的忙时排队消息快照(飞书 + 面板同队列),供面板展示队列列表
|
|
1409
|
+
queued: this.queuedSnapshotFor(pollWs),
|
|
1296
1410
|
}));
|
|
1297
1411
|
return;
|
|
1298
1412
|
}
|
|
1413
|
+
// 面板忙时排队:AI 正忙(该项目无等待中的请求)时,把面板消息排进忙时队列,
|
|
1414
|
+
// 与飞书消息共用同一队列(按到达顺序),下一轮 interactive_feedback 合并送达
|
|
1415
|
+
if (req.method === 'POST' && req.url === '/api/feedback/enqueue') {
|
|
1416
|
+
let body = '';
|
|
1417
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
1418
|
+
req.on('end', () => {
|
|
1419
|
+
try {
|
|
1420
|
+
const { text, images, attachedFiles, projectDir } = JSON.parse(body);
|
|
1421
|
+
if (!projectDir) {
|
|
1422
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1423
|
+
res.end(JSON.stringify({ queued: false, reason: 'projectDir required' }));
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
// 忙时排队是全局开关(面板 UI 已隐藏排队入口,这里兜底防竞态)
|
|
1427
|
+
if (!this.feishu.isQueueWhenBusy()) {
|
|
1428
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1429
|
+
res.end(JSON.stringify({ queued: false, reason: 'disabled' }));
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
// 该项目有等待中的请求 → 不该排队,面板应直接提交(正常轮询下一秒就会显示该请求)
|
|
1433
|
+
if (this.hasPendingForProject(projectDir)) {
|
|
1434
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1435
|
+
res.end(JSON.stringify({ queued: false, reason: 'pending' }));
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
this.enqueueInbound(text || '', '', images || [], attachedFiles || [], '', projectDir, 'panel');
|
|
1439
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1440
|
+
res.end(JSON.stringify({ queued: true }));
|
|
1441
|
+
}
|
|
1442
|
+
catch {
|
|
1443
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1444
|
+
res.end(JSON.stringify({ queued: false, reason: 'invalid body' }));
|
|
1445
|
+
}
|
|
1446
|
+
});
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
// 撤回排队消息:面板队列列表的删除按钮触发,按队列项 id 定位。
|
|
1450
|
+
// 飞书来源的消息由 removeQueuedById 同步回执,保证两边状态一致
|
|
1451
|
+
if (req.method === 'POST' && req.url === '/api/feedback/queue/remove') {
|
|
1452
|
+
let body = '';
|
|
1453
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
1454
|
+
req.on('end', () => {
|
|
1455
|
+
try {
|
|
1456
|
+
const { id } = JSON.parse(body);
|
|
1457
|
+
const removed = !!id && this.removeQueuedById(id);
|
|
1458
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1459
|
+
res.end(JSON.stringify({ removed }));
|
|
1460
|
+
}
|
|
1461
|
+
catch {
|
|
1462
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
1463
|
+
res.end(JSON.stringify({ removed: false }));
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1299
1468
|
// 提交反馈
|
|
1300
1469
|
if (req.method === 'POST' && req.url === '/api/feedback/submit') {
|
|
1301
1470
|
let body = '';
|
|
@@ -1342,6 +1511,30 @@ class McpFeedbackServer {
|
|
|
1342
1511
|
return;
|
|
1343
1512
|
}
|
|
1344
1513
|
// 配置飞书(来自插件 UI;凭证未变只补 chatId,变了则重建长连接)
|
|
1514
|
+
// 扫码一键创建飞书应用:启动 Device Grant 流程并返回待扫码链接(等二维码就绪才响应)
|
|
1515
|
+
if (req.method === 'POST' && req.url === '/api/feishu/register/start') {
|
|
1516
|
+
this.feishu.startRegister().then((state) => {
|
|
1517
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1518
|
+
res.end(JSON.stringify(state));
|
|
1519
|
+
}).catch(() => {
|
|
1520
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
1521
|
+
res.end(JSON.stringify({ status: 'error', error: 'internal' }));
|
|
1522
|
+
});
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
// 扫码创建流程状态(插件轮询直到 success / error)
|
|
1526
|
+
if (req.method === 'GET' && req.url === '/api/feishu/register/status') {
|
|
1527
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1528
|
+
res.end(JSON.stringify(this.feishu.getRegisterState()));
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
// 取消进行中的扫码创建流程(用户关闭设置弹窗)
|
|
1532
|
+
if (req.method === 'POST' && req.url === '/api/feishu/register/cancel') {
|
|
1533
|
+
this.feishu.cancelRegister();
|
|
1534
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1535
|
+
res.end(JSON.stringify({ ok: true }));
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1345
1538
|
if (req.method === 'POST' && req.url === '/api/feishu/config') {
|
|
1346
1539
|
let body = '';
|
|
1347
1540
|
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
@@ -1541,7 +1734,7 @@ class McpFeedbackServer {
|
|
|
1541
1734
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1542
1735
|
res.end(JSON.stringify({
|
|
1543
1736
|
status: 'ok',
|
|
1544
|
-
version:
|
|
1737
|
+
version: PKG_VERSION,
|
|
1545
1738
|
hasCurrentRequest: this.currentRequest !== null,
|
|
1546
1739
|
pid: process.pid,
|
|
1547
1740
|
}));
|
|
@@ -1792,12 +1985,8 @@ McpFeedbackServer.QUEUE_MAX = 100;
|
|
|
1792
1985
|
*(只剩别的窗口在扫端口)→ 视为我的窗口已关、本实例是僵尸:不再上报 pending,避免污染全局计数。
|
|
1793
1986
|
*/
|
|
1794
1987
|
McpFeedbackServer.OWNER_IDLE_MS = 12000;
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
* AI 每一轮的 summary 几乎不可能与上一轮一字不差,短窗口内完全相同基本可断定为
|
|
1798
|
-
* 客户端/传输层对同一次 tool call 的重复投递。
|
|
1799
|
-
*/
|
|
1800
|
-
McpFeedbackServer.DUP_JOIN_MS = 90000;
|
|
1988
|
+
McpFeedbackServer.SETTLED_WIRE_TTL_MS = 10 * 60 * 1000;
|
|
1989
|
+
McpFeedbackServer.SETTLED_WIRE_MAX = 20;
|
|
1801
1990
|
// 主函数
|
|
1802
1991
|
async function main() {
|
|
1803
1992
|
const port = 61927;
|
package/dist/webview/index.html
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/></svg>
|
|
21
21
|
</button>
|
|
22
22
|
<button id="autoRetryBtn" class="iconbtn" type="button" aria-label="Timeout keep-waiting / 超时续期" data-tip="Timeout keep-waiting / 超时续期">
|
|
23
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="
|
|
23
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>
|
|
24
24
|
</button>
|
|
25
25
|
<button id="themeAccentBtn" class="iconbtn" type="button" aria-label="Accent color / 主题色" data-tip="Accent color / 主题色">
|
|
26
26
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.555C21.965 6.012 17.461 2 12 2z"/></svg>
|
|
@@ -37,13 +37,20 @@
|
|
|
37
37
|
</div>
|
|
38
38
|
</header>
|
|
39
39
|
|
|
40
|
-
<!--
|
|
40
|
+
<!-- 等待态(排队模式下排队消息列表内嵌在此卡片中,整体高度由分隔条控制) -->
|
|
41
41
|
<section id="waitingStatus" class="empty">
|
|
42
42
|
<div class="empty__icon" aria-hidden="true">
|
|
43
43
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
|
44
44
|
</div>
|
|
45
45
|
<p class="empty__title">{{i18n.waitingForAI}}</p>
|
|
46
46
|
<p class="empty__hint">{{i18n.waitingHint}}</p>
|
|
47
|
+
<div id="queueCard" class="waiting-queue hidden">
|
|
48
|
+
<div class="waiting-queue__head">
|
|
49
|
+
<span class="waiting-queue__title">{{i18n.queuedTitle}}</span>
|
|
50
|
+
<span id="queueCount" class="queue-count"></span>
|
|
51
|
+
</div>
|
|
52
|
+
<div id="queueList" class="queue-list"></div>
|
|
53
|
+
</div>
|
|
47
54
|
</section>
|
|
48
55
|
|
|
49
56
|
<!-- 反馈表单 -->
|
|
@@ -55,6 +62,16 @@
|
|
|
55
62
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/></svg>
|
|
56
63
|
</span>
|
|
57
64
|
<h2 class="card__title">{{i18n.aiSummary}}</h2>
|
|
65
|
+
<!-- 历史摘要导航:存过 2 条以上时显示,可回看之前轮次的 AI 摘要 -->
|
|
66
|
+
<div id="summaryNav" class="summary-nav" hidden>
|
|
67
|
+
<button id="summaryPrevBtn" class="iconbtn summary-nav__btn" type="button" aria-label="{{i18n.summaryPrevTip}}" data-tip="{{i18n.summaryPrevTip}}">
|
|
68
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
|
|
69
|
+
</button>
|
|
70
|
+
<span id="summaryNavPos" class="summary-nav__pos"></span>
|
|
71
|
+
<button id="summaryNextBtn" class="iconbtn summary-nav__btn" type="button" aria-label="{{i18n.summaryNextTip}}" data-tip="{{i18n.summaryNextTip}}">
|
|
72
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
58
75
|
</div>
|
|
59
76
|
<div id="summaryContent" class="summary markdown-body"></div>
|
|
60
77
|
<div id="projectInfo" class="project-chip"></div>
|
|
@@ -183,7 +200,17 @@
|
|
|
183
200
|
</div>
|
|
184
201
|
</div>
|
|
185
202
|
|
|
186
|
-
<!--
|
|
203
|
+
<!-- 区块②:忙时消息排队(全局开关:飞书与面板消息共用忙时队列) -->
|
|
204
|
+
<div class="feishu-section">
|
|
205
|
+
<label class="feishu-switch">
|
|
206
|
+
<span class="feishu-switch__label feishu-switch__label--strong">{{i18n.queueWhenBusyLabel}}</span>
|
|
207
|
+
<input id="queueWhenBusyToggle" type="checkbox" class="feishu-switch__input">
|
|
208
|
+
<span class="feishu-switch__track"><span class="feishu-switch__thumb"></span></span>
|
|
209
|
+
</label>
|
|
210
|
+
<p class="feishu-modal__desc">{{i18n.queueWhenBusyDesc}}</p>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<!-- 区块③:飞书通知(推送开关 + 凭证 + 绑定状态) -->
|
|
187
214
|
<div class="feishu-section">
|
|
188
215
|
<label class="feishu-switch">
|
|
189
216
|
<span class="feishu-switch__label feishu-switch__label--strong">{{i18n.feishuSettings}}</span>
|
|
@@ -199,14 +226,6 @@
|
|
|
199
226
|
</label>
|
|
200
227
|
<p class="feishu-modal__desc feishu-modal__desc--sub">{{i18n.feishuAckDesc}}</p>
|
|
201
228
|
</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>
|
|
210
229
|
<label class="feishu-field">
|
|
211
230
|
<span class="feishu-field__label">{{i18n.feishuAppId}}</span>
|
|
212
231
|
<input id="feishuAppId" class="feishu-input" type="text" placeholder="{{i18n.feishuAppIdPlaceholder}}" autocomplete="off" spellcheck="false">
|
|
@@ -226,7 +245,16 @@
|
|
|
226
245
|
<span id="feishuStatusText" class="feishu-status__text">{{i18n.feishuStatusUnconfigured}}</span>
|
|
227
246
|
</div>
|
|
228
247
|
<div class="feishu-modal__actions">
|
|
229
|
-
<button id="
|
|
248
|
+
<button id="feishuRegisterBtn" class="feishu-link feishu-link--primary" type="button">{{i18n.feishuRegisterBtn}}</button>
|
|
249
|
+
</div>
|
|
250
|
+
<!-- 扫码一键创建应用:点击上方按钮后展开,展示验证二维码与流程状态 -->
|
|
251
|
+
<div id="feishuRegisterPanel" class="feishu-register hidden">
|
|
252
|
+
<img id="feishuRegisterQr" class="feishu-register__qr hidden" alt="QR code">
|
|
253
|
+
<p id="feishuRegisterHint" class="feishu-register__hint"></p>
|
|
254
|
+
<div class="feishu-register__actions">
|
|
255
|
+
<button id="feishuRegisterOpenBtn" class="feishu-link hidden" type="button">{{i18n.registerOpenLink}}</button>
|
|
256
|
+
<button id="feishuRegisterRetryBtn" class="feishu-link hidden" type="button">{{i18n.registerRetry}}</button>
|
|
257
|
+
</div>
|
|
230
258
|
</div>
|
|
231
259
|
<p class="feishu-modal__hint">{{i18n.feishuHint}}</p>
|
|
232
260
|
</div>
|