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 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 _handleOpenFeishuGuide() {
1053
- const onlineUrl = 'https://github.com/jianger666/cursor-feedback-extension/blob/main/docs/feishu-setup.md';
1054
- const guideUri = vscode.Uri.joinPath(this._extensionUri, 'docs', 'feishu-setup.md');
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
- // stat 确认打进包的本地文档在、再站内预览。
1057
- // markdown.showPreview 对缺失文件不报错、只渲染「找不到 feishu-setup.md」、catch 也不触发——
1058
- // 所以必须自己先校验存在、否则一旦打包又漏了 docs 就退回原 bug。
1059
- await vscode.workspace.fs.stat(guideUri);
1060
- await vscode.commands.executeCommand('markdown.showPreview', guideUri);
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
- // 本地没有(打包漏了 / 环境异常)→ 兜底开 GitHub 在线文档、绝不让用户再撞「找不到」
1064
- await vscode.env.openExternal(vscode.Uri.parse(onlineUrl));
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: 5000
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;