cursor-feedback 2.1.2 → 2.3.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 +250 -18
- package/dist/feishu.js +132 -2
- package/dist/i18n/en.json +23 -1
- package/dist/i18n/index.js +23 -1
- package/dist/i18n/zh-CN.json +23 -1
- package/dist/mcp-server.js +422 -13
- package/dist/webview/index.html +40 -4
- package/dist/webview/script.js +412 -21
- package/dist/webview/styles.css +215 -19
- package/package.json +4 -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.3.0](https://github.com/jianger666/cursor-feedback-extension/compare/v2.2.0...v2.3.0) (2026-07-04)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* 面板消息排队与撤回、扫码建应用、摘要历史与多项 UI 体验优化 ([ffe731c](https://github.com/jianger666/cursor-feedback-extension/commit/ffe731c767975f2045fc4b8701500b6a60f48373))
|
|
11
|
+
|
|
12
|
+
## [2.2.0](https://github.com/jianger666/cursor-feedback-extension/compare/v2.1.2...v2.2.0) (2026-07-03)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **feishu:** 忙时消息排队——AI 干活期间的飞书消息不再丢失 ([fd8aa75](https://github.com/jianger666/cursor-feedback-extension/commit/fd8aa7540a787dbb44e85f35ea137a0c0ea9c03e))
|
|
18
|
+
|
|
5
19
|
### [2.1.2](https://github.com/jianger666/cursor-feedback-extension/compare/v2.1.1...v2.1.2) (2026-07-03)
|
|
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
|
@@ -177,6 +177,11 @@ class FeedbackViewProvider {
|
|
|
177
177
|
this._feishuEnabled = true;
|
|
178
178
|
// Get 表情回执子开关(飞书通知子项;关掉后用户飞书回复不加 Get 表情、也不发文字兜底)
|
|
179
179
|
this._feishuAck = true;
|
|
180
|
+
// 忙时消息排队子开关(飞书通知子项;AI 正忙时用户消息入队,下一轮 feedback 自动送达)
|
|
181
|
+
this._feishuQueue = true;
|
|
182
|
+
// 最近一次下发给 webview 的队列快照(签名去重避免每秒刷屏;webview 重建后凭此恢复列表)
|
|
183
|
+
this._lastQueueSig = '';
|
|
184
|
+
this._lastQueueItems = [];
|
|
180
185
|
this._debugInfo = {
|
|
181
186
|
portRange: '',
|
|
182
187
|
workspacePath: '',
|
|
@@ -184,6 +189,9 @@ class FeedbackViewProvider {
|
|
|
184
189
|
activePort: null,
|
|
185
190
|
lastStatus: ''
|
|
186
191
|
};
|
|
192
|
+
// ---------- 扫码一键创建飞书应用 ----------
|
|
193
|
+
this._registerPollTimer = null;
|
|
194
|
+
this._registerPort = null;
|
|
187
195
|
this._basePort = port;
|
|
188
196
|
this._debugInfo.portRange = `${port}-${port + this._portScanRange - 1}`;
|
|
189
197
|
this._i18n = (0, i18n_1.loadMessages)(this._extensionUri.fsPath);
|
|
@@ -194,6 +202,7 @@ class FeedbackViewProvider {
|
|
|
194
202
|
this._feishuConfig = { appId: fc.appId || '', appSecret: fc.appSecret || '' };
|
|
195
203
|
this._feishuEnabled = this._memento?.get('feishuEnabled', true) ?? true;
|
|
196
204
|
this._feishuAck = this._memento?.get('feishuAck', true) ?? true;
|
|
205
|
+
this._feishuQueue = this._memento?.get('feishuQueue', true) ?? true;
|
|
197
206
|
}
|
|
198
207
|
/**
|
|
199
208
|
* 获取翻译消息
|
|
@@ -225,11 +234,22 @@ class FeedbackViewProvider {
|
|
|
225
234
|
case 'submitFeedback':
|
|
226
235
|
await this._handleFeedbackSubmit(data.payload);
|
|
227
236
|
break;
|
|
237
|
+
case 'queueMessage':
|
|
238
|
+
await this._handleQueueMessage(data.payload);
|
|
239
|
+
break;
|
|
240
|
+
case 'removeQueued':
|
|
241
|
+
await this._handleRemoveQueued(data.payload);
|
|
242
|
+
break;
|
|
228
243
|
case 'ready':
|
|
229
244
|
this._webviewReady = true;
|
|
230
245
|
// 同步超时续期开关状态到 UI
|
|
231
246
|
this._postAutoRetryState();
|
|
232
247
|
this._postFeishuState();
|
|
248
|
+
// 重建后的 webview 恢复队列列表显示(poll 之后每秒还会校准)
|
|
249
|
+
this._view?.webview.postMessage({
|
|
250
|
+
type: 'queueState',
|
|
251
|
+
payload: { items: this._lastQueueItems }
|
|
252
|
+
});
|
|
233
253
|
// WebView 准备就绪后,检查是否有待处理的请求
|
|
234
254
|
// (插件通知关闭时保持静默,不显示缓存的请求)
|
|
235
255
|
if (this._currentRequest && this._isPluginNotifyEnabled()) {
|
|
@@ -262,9 +282,6 @@ class FeedbackViewProvider {
|
|
|
262
282
|
case 'saveFeishuConfig':
|
|
263
283
|
await this._handleSaveFeishuConfig(data.payload);
|
|
264
284
|
break;
|
|
265
|
-
case 'openFeishuGuide':
|
|
266
|
-
await this._handleOpenFeishuGuide();
|
|
267
|
-
break;
|
|
268
285
|
case 'toggleFeishuEnabled':
|
|
269
286
|
await this._handleToggleFeishuEnabled(!!data.payload?.enabled);
|
|
270
287
|
break;
|
|
@@ -277,6 +294,18 @@ class FeedbackViewProvider {
|
|
|
277
294
|
case 'toggleFeishuAck':
|
|
278
295
|
await this._handleToggleFeishuAck(!!data.payload?.enabled);
|
|
279
296
|
break;
|
|
297
|
+
case 'toggleFeishuQueue':
|
|
298
|
+
await this._handleToggleFeishuQueue(!!data.payload?.enabled);
|
|
299
|
+
break;
|
|
300
|
+
case 'feishuRegisterStart':
|
|
301
|
+
await this._handleFeishuRegisterStart();
|
|
302
|
+
break;
|
|
303
|
+
case 'feishuRegisterCancel':
|
|
304
|
+
this._handleFeishuRegisterCancel();
|
|
305
|
+
break;
|
|
306
|
+
case 'openLink':
|
|
307
|
+
this._handleOpenLink(data.payload?.url);
|
|
308
|
+
break;
|
|
280
309
|
case 'testNotification':
|
|
281
310
|
this._sendTestNotification();
|
|
282
311
|
break;
|
|
@@ -352,6 +381,8 @@ class FeedbackViewProvider {
|
|
|
352
381
|
// 端口仍然有效,保持使用
|
|
353
382
|
this._debugInfo.connectedPorts = [this._activePort];
|
|
354
383
|
this._debugInfo.activePort = this._activePort;
|
|
384
|
+
// 同步忙时队列快照到面板(签名去重,无变化不发);打上端口标,撤回时按它路由
|
|
385
|
+
this._postQueueState((result.queued || []).map(q => ({ ...q, port: this._activePort })));
|
|
355
386
|
// 只挡「已提交/已结束」的请求;_handleNewRequest 内部按当前显示态去重,重复调用无害
|
|
356
387
|
if (result.request && !this._resolvedRequestIds.has(result.request.id)) {
|
|
357
388
|
this._debugInfo.lastStatus = this._t('statusListening', { port: this._activePort });
|
|
@@ -384,6 +415,12 @@ class FeedbackViewProvider {
|
|
|
384
415
|
const results = await Promise.all(ports.map(port => this._checkPortForRequest(port)));
|
|
385
416
|
// 更新已连接的端口列表
|
|
386
417
|
this._debugInfo.connectedPorts = results.filter(r => r.connected).map(r => r.port);
|
|
418
|
+
// 汇总各 server 返回的本工作区忙时队列(正常只有归属本窗口的 server 有内容),按时间排序下发;
|
|
419
|
+
// 打上各自 server 的端口标,撤回时按它路由
|
|
420
|
+
const queuedAll = results
|
|
421
|
+
.flatMap(r => (r.connected && Array.isArray(r.queued) ? r.queued.map(q => ({ ...q, port: r.port })) : []))
|
|
422
|
+
.sort((a, b) => a.at - b.at);
|
|
423
|
+
this._postQueueState(queuedAll);
|
|
387
424
|
// 找出属于当前工作区的请求(只排除已提交/已结束的;「见过但没提交」的仍要能回到面板)。
|
|
388
425
|
// 不再用 server 进程级 ownerWorkspace 二次过滤:request 已在 _checkPortForRequest
|
|
389
426
|
// 按本窗口工作区过滤过(isPathInWorkspace);多窗口共用一个 MCP 进程时 owner 只反映
|
|
@@ -451,11 +488,14 @@ class FeedbackViewProvider {
|
|
|
451
488
|
let request;
|
|
452
489
|
let ownerWorkspace = null;
|
|
453
490
|
let feishuResolvedId = null;
|
|
491
|
+
let queued = [];
|
|
454
492
|
if (parsed && typeof parsed === 'object' && 'startTime' in parsed) {
|
|
455
493
|
// 新格式
|
|
456
494
|
request = parsed.request;
|
|
457
495
|
ownerWorkspace = parsed.ownerWorkspace;
|
|
458
496
|
feishuResolvedId = parsed.feishuResolvedId ?? null;
|
|
497
|
+
if (Array.isArray(parsed.queued))
|
|
498
|
+
queued = parsed.queued;
|
|
459
499
|
this._maybeSyncFeishu(port, parsed.feishu);
|
|
460
500
|
this._maybeSyncAutoRetry(parsed.autoRetry);
|
|
461
501
|
this._maybeSyncPause(parsed.pause);
|
|
@@ -469,10 +509,10 @@ class FeedbackViewProvider {
|
|
|
469
509
|
const isMatch = isPathInWorkspace(request.projectDir);
|
|
470
510
|
if (!isMatch) {
|
|
471
511
|
// 请求不属于当前工作区,返回特殊标记
|
|
472
|
-
return { connected: true, request: null, port, mismatch: true, ownerWorkspace };
|
|
512
|
+
return { connected: true, request: null, port, mismatch: true, ownerWorkspace, queued };
|
|
473
513
|
}
|
|
474
514
|
}
|
|
475
|
-
return { connected: true, request, port, ownerWorkspace, feishuResolvedId };
|
|
515
|
+
return { connected: true, request, port, ownerWorkspace, feishuResolvedId, queued };
|
|
476
516
|
}
|
|
477
517
|
catch {
|
|
478
518
|
return { connected: false, request: null, port };
|
|
@@ -760,6 +800,103 @@ class FeedbackViewProvider {
|
|
|
760
800
|
vscode.window.showErrorMessage(this._i18n.submitFailed + ': ' + this._i18n.cannotConnectMCP);
|
|
761
801
|
}
|
|
762
802
|
}
|
|
803
|
+
/**
|
|
804
|
+
* 把忙时队列快照推给 webview(签名去重:队列无变化时每秒轮询不重复发)
|
|
805
|
+
*/
|
|
806
|
+
_postQueueState(items) {
|
|
807
|
+
const sig = JSON.stringify(items);
|
|
808
|
+
if (sig === this._lastQueueSig)
|
|
809
|
+
return;
|
|
810
|
+
this._lastQueueSig = sig;
|
|
811
|
+
this._lastQueueItems = items;
|
|
812
|
+
this._view?.webview.postMessage({ type: 'queueState', payload: { items } });
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* 面板忙时排队:AI 正忙(没有等待中的反馈请求)时用户在面板点了「排队发送」。
|
|
816
|
+
* 先定位归属本工作区的 server(AI 的下一轮 interactive_feedback 会走同一个 MCP 进程,
|
|
817
|
+
* 队列必须排在那里才能被消费),再 POST 入队。找不到归属 server = AI 没在本项目工作,
|
|
818
|
+
* 明确告知失败,绝不静默吞消息。
|
|
819
|
+
*/
|
|
820
|
+
async _handleQueueMessage(payload) {
|
|
821
|
+
const workspacePaths = getWorkspacePaths();
|
|
822
|
+
const workspacePath = workspacePaths.length > 0 ? workspacePaths[0] : '';
|
|
823
|
+
const fail = (reason) => {
|
|
824
|
+
this._view?.webview.postMessage({ type: 'queueSubmitted', payload: { success: false, reason } });
|
|
825
|
+
};
|
|
826
|
+
if (!workspacePath) {
|
|
827
|
+
fail('no-server');
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const port = await this._findOwnerPort(workspacePath);
|
|
831
|
+
if (!port) {
|
|
832
|
+
fail('no-server');
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
const response = await this._httpPost(`http://127.0.0.1:${port}/api/feedback/enqueue`, JSON.stringify({
|
|
837
|
+
text: payload.interactive_feedback,
|
|
838
|
+
images: payload.images || [],
|
|
839
|
+
attachedFiles: payload.attachedFiles || [],
|
|
840
|
+
projectDir: workspacePath
|
|
841
|
+
}));
|
|
842
|
+
const result = JSON.parse(response);
|
|
843
|
+
if (result.queued) {
|
|
844
|
+
this._view?.webview.postMessage({ type: 'queueSubmitted', payload: { success: true } });
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
fail(result.reason === 'pending' || result.reason === 'disabled' ? result.reason : 'no-server');
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
fail('no-server');
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* 撤回一条排队消息:按队列项携带的端口路由到所在 server。
|
|
856
|
+
* webview 已做乐观移除;失败也无需提示——下一秒轮询会把仍在队列里的消息补回列表。
|
|
857
|
+
*/
|
|
858
|
+
async _handleRemoveQueued(payload) {
|
|
859
|
+
if (!payload || !payload.id || typeof payload.port !== 'number')
|
|
860
|
+
return;
|
|
861
|
+
try {
|
|
862
|
+
await this._httpPost(`http://127.0.0.1:${payload.port}/api/feedback/queue/remove`, JSON.stringify({ id: payload.id }));
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
// 静默:轮询会校准列表
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* 定位归属指定工作区的 MCP server 端口(ownerWorkspace 与工作区路径互为前缀)。
|
|
870
|
+
* 优先试当前已知端口,避免全端口扫描;都不中再并行扫描全范围。
|
|
871
|
+
*/
|
|
872
|
+
async _findOwnerPort(workspacePath) {
|
|
873
|
+
const normalizedWs = normalizePath(workspacePath);
|
|
874
|
+
const isOwner = async (port) => {
|
|
875
|
+
try {
|
|
876
|
+
const response = await this._httpGet(`http://127.0.0.1:${port}/api/feedback/current?workspace=${encodeURIComponent(workspacePath)}`);
|
|
877
|
+
const parsed = JSON.parse(response);
|
|
878
|
+
const owner = parsed && parsed.ownerWorkspace ? normalizePath(parsed.ownerWorkspace) : '';
|
|
879
|
+
return !!owner && pathsRelated(owner, normalizedWs);
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
const preferred = [this._currentRequestPort, this._activePort]
|
|
886
|
+
.filter((p) => typeof p === 'number');
|
|
887
|
+
for (const p of preferred) {
|
|
888
|
+
if (await isOwner(p))
|
|
889
|
+
return p;
|
|
890
|
+
}
|
|
891
|
+
const ports = [];
|
|
892
|
+
for (let i = 0; i < this._portScanRange; i++) {
|
|
893
|
+
const p = this._basePort + i;
|
|
894
|
+
if (!preferred.includes(p))
|
|
895
|
+
ports.push(p);
|
|
896
|
+
}
|
|
897
|
+
const hits = await Promise.all(ports.map(async (p) => ((await isOwner(p)) ? p : null)));
|
|
898
|
+
return hits.find((p) => p !== null) ?? null;
|
|
899
|
+
}
|
|
763
900
|
/**
|
|
764
901
|
* 处理选择文件/文件夹
|
|
765
902
|
*/
|
|
@@ -973,6 +1110,7 @@ class FeedbackViewProvider {
|
|
|
973
1110
|
bound: this._feishuBound,
|
|
974
1111
|
feishuEnabled: this._feishuEnabled,
|
|
975
1112
|
feishuAck: this._feishuAck,
|
|
1113
|
+
feishuQueue: this._feishuQueue,
|
|
976
1114
|
systemNotification: !!systemNotification,
|
|
977
1115
|
osNotification: !!osNotification
|
|
978
1116
|
}
|
|
@@ -1007,6 +1145,7 @@ class FeedbackViewProvider {
|
|
|
1007
1145
|
const appSecret = feishuStatus.appSecret || '';
|
|
1008
1146
|
const enabled = feishuStatus.enabled !== false;
|
|
1009
1147
|
const ack = feishuStatus.ackReaction !== false;
|
|
1148
|
+
const queue = feishuStatus.queueWhenBusy !== false;
|
|
1010
1149
|
const bound = !!feishuStatus.boundChatId;
|
|
1011
1150
|
let changed = false;
|
|
1012
1151
|
if (appId !== this._feishuConfig.appId || appSecret !== this._feishuConfig.appSecret) {
|
|
@@ -1023,6 +1162,10 @@ class FeedbackViewProvider {
|
|
|
1023
1162
|
this._feishuAck = ack;
|
|
1024
1163
|
changed = true;
|
|
1025
1164
|
}
|
|
1165
|
+
if (queue !== this._feishuQueue) {
|
|
1166
|
+
this._feishuQueue = queue;
|
|
1167
|
+
changed = true;
|
|
1168
|
+
}
|
|
1026
1169
|
if (bound !== this._feishuBound) {
|
|
1027
1170
|
this._feishuBound = bound;
|
|
1028
1171
|
changed = true;
|
|
@@ -1041,29 +1184,109 @@ class FeedbackViewProvider {
|
|
|
1041
1184
|
appSecret,
|
|
1042
1185
|
enabled: this._feishuEnabled,
|
|
1043
1186
|
ackReaction: this._feishuAck,
|
|
1187
|
+
queueWhenBusy: this._feishuQueue,
|
|
1044
1188
|
});
|
|
1045
1189
|
for (const port of ports) {
|
|
1046
1190
|
this._httpPost(`http://127.0.0.1:${port}/api/feishu/config`, body).catch(() => { });
|
|
1047
1191
|
}
|
|
1048
1192
|
}
|
|
1193
|
+
_postRegisterState(payload) {
|
|
1194
|
+
this._view?.webview.postMessage({ type: 'feishuRegisterState', payload });
|
|
1195
|
+
}
|
|
1049
1196
|
/**
|
|
1050
|
-
*
|
|
1197
|
+
* 发起扫码创建:找一个活跃的 MCP server(凭证写磁盘全局共享,任一进程均可),
|
|
1198
|
+
* 拿到验证链接后本地生成二维码 dataURL 推给面板,随后轮询直到成功/失败。
|
|
1051
1199
|
*/
|
|
1052
|
-
async
|
|
1053
|
-
|
|
1054
|
-
const
|
|
1200
|
+
async _handleFeishuRegisterStart() {
|
|
1201
|
+
this._stopRegisterPolling();
|
|
1202
|
+
const port = await this._findAnyServerPort();
|
|
1203
|
+
if (!port) {
|
|
1204
|
+
this._postRegisterState({ status: 'error', error: this._i18n.registerNoServer });
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
this._registerPort = port;
|
|
1055
1208
|
try {
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1209
|
+
// register/start 要等二维码就绪(服务端最多 10s)才响应,超时给足余量。
|
|
1210
|
+
// 二维码 dataURL 由 server 生成(插件 VSIX 不带 node_modules,装不进二维码库)
|
|
1211
|
+
const raw = await this._httpPost(`http://127.0.0.1:${port}/api/feishu/register/start`, '{}', 15000);
|
|
1212
|
+
const state = JSON.parse(raw);
|
|
1213
|
+
if (state.status === 'waiting' && state.url) {
|
|
1214
|
+
this._postRegisterState({ status: 'waiting', url: state.url, expireIn: state.expireIn, qr: state.qr });
|
|
1215
|
+
this._startRegisterPolling(port);
|
|
1216
|
+
}
|
|
1217
|
+
else {
|
|
1218
|
+
this._postRegisterState({ status: 'error', error: state.error || 'failed' });
|
|
1219
|
+
}
|
|
1061
1220
|
}
|
|
1062
1221
|
catch {
|
|
1063
|
-
|
|
1064
|
-
|
|
1222
|
+
this._postRegisterState({ status: 'error', error: this._i18n.registerNoServer });
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
_handleFeishuRegisterCancel() {
|
|
1226
|
+
this._stopRegisterPolling();
|
|
1227
|
+
if (this._registerPort) {
|
|
1228
|
+
this._httpPost(`http://127.0.0.1:${this._registerPort}/api/feishu/register/cancel`, '{}').catch(() => { });
|
|
1229
|
+
this._registerPort = null;
|
|
1065
1230
|
}
|
|
1066
1231
|
}
|
|
1232
|
+
_startRegisterPolling(port) {
|
|
1233
|
+
this._stopRegisterPolling();
|
|
1234
|
+
this._registerPollTimer = setInterval(async () => {
|
|
1235
|
+
try {
|
|
1236
|
+
const raw = await this._httpGet(`http://127.0.0.1:${port}/api/feishu/register/status`);
|
|
1237
|
+
const st = JSON.parse(raw);
|
|
1238
|
+
if (st.status === 'success') {
|
|
1239
|
+
this._stopRegisterPolling();
|
|
1240
|
+
this._postRegisterState({ status: 'success', appId: st.appId });
|
|
1241
|
+
}
|
|
1242
|
+
else if (st.status === 'error' || st.status === 'idle') {
|
|
1243
|
+
this._stopRegisterPolling();
|
|
1244
|
+
this._postRegisterState({ status: 'error', error: st.error || 'failed' });
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
catch {
|
|
1248
|
+
// server 暂时不可达:下一轮再试
|
|
1249
|
+
}
|
|
1250
|
+
}, 2000);
|
|
1251
|
+
}
|
|
1252
|
+
_stopRegisterPolling() {
|
|
1253
|
+
if (this._registerPollTimer) {
|
|
1254
|
+
clearInterval(this._registerPollTimer);
|
|
1255
|
+
this._registerPollTimer = null;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
/** 找任意一个可达的 MCP server 端口(优先当前活跃端口,避免全端口扫描) */
|
|
1259
|
+
async _findAnyServerPort() {
|
|
1260
|
+
const alive = async (port) => {
|
|
1261
|
+
try {
|
|
1262
|
+
const raw = await this._httpGet(`http://127.0.0.1:${port}/api/health`);
|
|
1263
|
+
return JSON.parse(raw).status === 'ok';
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
const preferred = [this._activePort, this._currentRequestPort, ...(this._debugInfo.connectedPorts || [])]
|
|
1270
|
+
.filter((p) => typeof p === 'number');
|
|
1271
|
+
for (const p of preferred) {
|
|
1272
|
+
if (await alive(p))
|
|
1273
|
+
return p;
|
|
1274
|
+
}
|
|
1275
|
+
const ports = [];
|
|
1276
|
+
for (let i = 0; i < this._portScanRange; i++) {
|
|
1277
|
+
const p = this._basePort + i;
|
|
1278
|
+
if (!preferred.includes(p))
|
|
1279
|
+
ports.push(p);
|
|
1280
|
+
}
|
|
1281
|
+
const hits = await Promise.all(ports.map(async (p) => ((await alive(p)) ? p : null)));
|
|
1282
|
+
return hits.find((p) => p !== null) ?? null;
|
|
1283
|
+
}
|
|
1284
|
+
/** 打开外部链接(webview 内点「打开链接」时用;仅放行 http/https) */
|
|
1285
|
+
_handleOpenLink(url) {
|
|
1286
|
+
if (!url || !/^https?:\/\//i.test(url))
|
|
1287
|
+
return;
|
|
1288
|
+
vscode.env.openExternal(vscode.Uri.parse(url));
|
|
1289
|
+
}
|
|
1067
1290
|
/**
|
|
1068
1291
|
* 切换飞书通知开关(关了即使已配置也不推飞书;长连接仍保留以便绑定/回复)
|
|
1069
1292
|
*/
|
|
@@ -1100,6 +1323,15 @@ class FeedbackViewProvider {
|
|
|
1100
1323
|
this._broadcastFeishuConfig();
|
|
1101
1324
|
this._postFeishuState();
|
|
1102
1325
|
}
|
|
1326
|
+
/**
|
|
1327
|
+
* 切换「忙时消息排队」子开关(飞书通知的子项;透传到 server 端,控制忙时队列)
|
|
1328
|
+
*/
|
|
1329
|
+
async _handleToggleFeishuQueue(enabled) {
|
|
1330
|
+
this._feishuQueue = enabled;
|
|
1331
|
+
await this._memento?.update('feishuQueue', enabled);
|
|
1332
|
+
this._broadcastFeishuConfig();
|
|
1333
|
+
this._postFeishuState();
|
|
1334
|
+
}
|
|
1103
1335
|
/**
|
|
1104
1336
|
* HTTP GET 请求
|
|
1105
1337
|
*/
|
|
@@ -1120,7 +1352,7 @@ class FeedbackViewProvider {
|
|
|
1120
1352
|
/**
|
|
1121
1353
|
* HTTP POST 请求
|
|
1122
1354
|
*/
|
|
1123
|
-
_httpPost(url, body) {
|
|
1355
|
+
_httpPost(url, body, timeoutMs = 5000) {
|
|
1124
1356
|
return new Promise((resolve, reject) => {
|
|
1125
1357
|
const urlObj = new URL(url);
|
|
1126
1358
|
const options = {
|
|
@@ -1132,7 +1364,7 @@ class FeedbackViewProvider {
|
|
|
1132
1364
|
'Content-Type': 'application/json',
|
|
1133
1365
|
'Content-Length': Buffer.byteLength(body)
|
|
1134
1366
|
},
|
|
1135
|
-
timeout:
|
|
1367
|
+
timeout: timeoutMs
|
|
1136
1368
|
};
|
|
1137
1369
|
const req = http.request(options, (res) => {
|
|
1138
1370
|
let data = '';
|
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,12 +73,18 @@ 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 注入,负责路由 / 转发) */
|
|
77
82
|
this.onReply = null;
|
|
78
83
|
/** 绑定的 chat_id 变化时的回调(由 mcp-server 注入,用于通知 extension 持久化) */
|
|
79
84
|
this.onBindChange = null;
|
|
85
|
+
// ---------- 扫码一键创建应用(registerApp / Device Grant) ----------
|
|
86
|
+
this.registerState = { status: 'idle' };
|
|
87
|
+
this.registerAbort = null;
|
|
80
88
|
/** 按 chatId 暂存待合并的入站消息 */
|
|
81
89
|
this.inboundBuffer = new Map();
|
|
82
90
|
}
|
|
@@ -92,14 +100,128 @@ class FeishuBridge {
|
|
|
92
100
|
connected: this.connected,
|
|
93
101
|
enabled: this.enabled,
|
|
94
102
|
ackReaction: this.ackReaction,
|
|
103
|
+
queueWhenBusy: this.queueWhenBusy,
|
|
95
104
|
appId: this.config?.appId || '',
|
|
96
105
|
appSecret: this.config?.appSecret || '',
|
|
97
106
|
boundChatId: this.getBoundChatId(),
|
|
98
107
|
};
|
|
99
108
|
}
|
|
109
|
+
isQueueWhenBusy() {
|
|
110
|
+
return this.queueWhenBusy;
|
|
111
|
+
}
|
|
100
112
|
isConfigured() {
|
|
101
113
|
return !!this.config;
|
|
102
114
|
}
|
|
115
|
+
getRegisterState() {
|
|
116
|
+
return this.registerState;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* 启动「扫码一键创建应用」:返回待扫码的验证链接(等 onQRCodeReady 就绪后才返回)。
|
|
120
|
+
* 用户在飞书里确认后 SDK resolve 出凭证 → 与手动在面板保存等价(写磁盘 touched + configure 立即生效)。
|
|
121
|
+
* 已有等待中的流程直接复用当前二维码,不重复发起。
|
|
122
|
+
*/
|
|
123
|
+
async startRegister() {
|
|
124
|
+
if (this.registerState.status === 'waiting' && this.registerAbort) {
|
|
125
|
+
return this.registerState;
|
|
126
|
+
}
|
|
127
|
+
if (!this.lark) {
|
|
128
|
+
try {
|
|
129
|
+
this.lark = await Promise.resolve().then(() => __importStar(require('@larksuiteoapi/node-sdk')));
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
flog('飞书 SDK 加载失败: ' + e);
|
|
133
|
+
this.registerState = { status: 'error', error: 'sdk_load_failed' };
|
|
134
|
+
return this.registerState;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (typeof this.lark.registerApp !== 'function') {
|
|
138
|
+
this.registerState = { status: 'error', error: 'sdk_too_old' };
|
|
139
|
+
return this.registerState;
|
|
140
|
+
}
|
|
141
|
+
const abort = new AbortController();
|
|
142
|
+
this.registerAbort = abort;
|
|
143
|
+
const state = { status: 'waiting' };
|
|
144
|
+
this.registerState = state;
|
|
145
|
+
let urlResolve = () => { };
|
|
146
|
+
const urlReady = new Promise((resolve) => {
|
|
147
|
+
urlResolve = resolve;
|
|
148
|
+
setTimeout(resolve, 10000); // 兜底:10s 拿不到二维码就按错误返回
|
|
149
|
+
});
|
|
150
|
+
this.lark
|
|
151
|
+
.registerApp({
|
|
152
|
+
signal: abort.signal,
|
|
153
|
+
onQRCodeReady: (info) => {
|
|
154
|
+
state.url = info.url;
|
|
155
|
+
state.expireIn = info.expireIn;
|
|
156
|
+
urlResolve();
|
|
157
|
+
},
|
|
158
|
+
appPreset: {
|
|
159
|
+
name: 'Cursor Feedback',
|
|
160
|
+
desc: 'Cursor AI 交互反馈通知机器人(由 cursor-feedback 插件扫码创建)',
|
|
161
|
+
},
|
|
162
|
+
// 平台基础模板已含收发消息 / reaction / 长连接事件;这里增量补齐本插件用到的资源权限。
|
|
163
|
+
// 平台不认识的名字会在确认页被静默丢弃,不会导致流程失败。
|
|
164
|
+
addons: {
|
|
165
|
+
scopes: { tenant: ['im:message', 'im:message:send_as_bot', 'im:resource'] },
|
|
166
|
+
events: { items: { tenant: ['im.message.receive_v1'] } },
|
|
167
|
+
},
|
|
168
|
+
// 只允许创建新应用:避免用户误选已有应用、其配置被覆盖
|
|
169
|
+
createOnly: true,
|
|
170
|
+
})
|
|
171
|
+
.then(async (result) => {
|
|
172
|
+
if (this.registerState !== state)
|
|
173
|
+
return; // 已被取消/新流程覆盖
|
|
174
|
+
const cfg = {
|
|
175
|
+
appId: result.client_id,
|
|
176
|
+
appSecret: result.client_secret,
|
|
177
|
+
enabled: this.enabled,
|
|
178
|
+
ackReaction: this.ackReaction,
|
|
179
|
+
queueWhenBusy: this.queueWhenBusy,
|
|
180
|
+
};
|
|
181
|
+
this.writePersistedConfig({ ...cfg, touched: true });
|
|
182
|
+
await this.configure(cfg);
|
|
183
|
+
state.status = 'success';
|
|
184
|
+
state.appId = result.client_id;
|
|
185
|
+
state.url = undefined;
|
|
186
|
+
flog(`扫码创建应用成功: ${result.client_id}`);
|
|
187
|
+
})
|
|
188
|
+
.catch((e) => {
|
|
189
|
+
if (this.registerState !== state)
|
|
190
|
+
return;
|
|
191
|
+
state.status = 'error';
|
|
192
|
+
state.url = undefined;
|
|
193
|
+
state.error = [e?.code, e?.description].filter(Boolean).join(': ') || String(e);
|
|
194
|
+
flog(`扫码创建应用失败: ${state.error}`);
|
|
195
|
+
});
|
|
196
|
+
await urlReady;
|
|
197
|
+
if (state.status === 'waiting' && !state.url) {
|
|
198
|
+
abort.abort();
|
|
199
|
+
this.registerAbort = null;
|
|
200
|
+
this.registerState = { status: 'error', error: 'qr_timeout' };
|
|
201
|
+
return this.registerState;
|
|
202
|
+
}
|
|
203
|
+
// 二维码在 server 侧生成(插件 VSIX 不带 node_modules);失败则面板退化为只显示链接按钮
|
|
204
|
+
if (state.status === 'waiting' && state.url) {
|
|
205
|
+
try {
|
|
206
|
+
const QRCode = await Promise.resolve().then(() => __importStar(require('qrcode')));
|
|
207
|
+
state.qr = await QRCode.toDataURL(state.url, { margin: 1, width: 220 });
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
flog('二维码生成失败(退化为链接): ' + e);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return this.registerState;
|
|
214
|
+
}
|
|
215
|
+
/** 取消进行中的扫码创建流程(用户关闭弹窗 / 主动取消) */
|
|
216
|
+
cancelRegister() {
|
|
217
|
+
if (this.registerAbort) {
|
|
218
|
+
this.registerAbort.abort();
|
|
219
|
+
this.registerAbort = null;
|
|
220
|
+
}
|
|
221
|
+
if (this.registerState.status === 'waiting') {
|
|
222
|
+
this.registerState = { status: 'idle' };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
103
225
|
/** 读取当前 appId 对应的绑定 chat_id(来自磁盘,跨进程共享) */
|
|
104
226
|
getBoundChatId() {
|
|
105
227
|
if (!this.config?.appId)
|
|
@@ -175,9 +297,10 @@ class FeishuBridge {
|
|
|
175
297
|
* - 凭证为空:视为「关闭飞书」
|
|
176
298
|
*/
|
|
177
299
|
async configure(config) {
|
|
178
|
-
// 通知开关 / Get
|
|
300
|
+
// 通知开关 / Get 表情回执开关 / 排队开关随时可改(与凭证无关),总是更新
|
|
179
301
|
this.enabled = config.enabled !== false;
|
|
180
302
|
this.ackReaction = config.ackReaction !== false;
|
|
303
|
+
this.queueWhenBusy = config.queueWhenBusy !== false;
|
|
181
304
|
const sameCred = this.config &&
|
|
182
305
|
this.config.appId === config.appId &&
|
|
183
306
|
this.config.appSecret === config.appSecret;
|
|
@@ -514,11 +637,14 @@ class FeishuBridge {
|
|
|
514
637
|
break;
|
|
515
638
|
const oldReq = this.cardToRequest.get(oldest);
|
|
516
639
|
this.cardToRequest.delete(oldest);
|
|
517
|
-
if (oldReq !== undefined)
|
|
640
|
+
if (oldReq !== undefined) {
|
|
518
641
|
this.requestToCard.delete(oldReq);
|
|
642
|
+
this.requestProjects.delete(oldReq);
|
|
643
|
+
}
|
|
519
644
|
}
|
|
520
645
|
this.cardToRequest.set(messageId, requestId);
|
|
521
646
|
this.requestToCard.set(requestId, messageId);
|
|
647
|
+
this.requestProjects.set(requestId, projectDir);
|
|
522
648
|
this.pendingSummaries.set(requestId, { summary, projectDir });
|
|
523
649
|
}
|
|
524
650
|
return messageId;
|
|
@@ -534,6 +660,10 @@ class FeishuBridge {
|
|
|
534
660
|
return null;
|
|
535
661
|
return this.cardToRequest.get(parentId) || null;
|
|
536
662
|
}
|
|
663
|
+
/** 查某 requestId 卡片的归属项目空间(请求结束后仍可查,用于忙时排队路由) */
|
|
664
|
+
projectDirOf(requestId) {
|
|
665
|
+
return this.requestProjects.get(requestId) || null;
|
|
666
|
+
}
|
|
537
667
|
/** 本实例当前待回复的请求数(用于多窗口判断) */
|
|
538
668
|
pendingCount() {
|
|
539
669
|
return this.pendingSummaries.size;
|