@xmanrui/dsh-im 4.32.0 → 4.34.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.32.0",
3
+ "version": "4.34.0",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
5
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
6
6
  "keywords": [
@@ -26,76 +26,6 @@
26
26
  "ai-office"
27
27
  ],
28
28
  "author": "xmanrui (https://github.com/xmanrui)",
29
- "contributors": [
30
- {
31
- "name": "C3H3-AI",
32
- "url": "https://github.com/C3H3-AI"
33
- },
34
- {
35
- "name": "divingleee",
36
- "url": "https://github.com/divingleee"
37
- },
38
- {
39
- "name": "Chan-0312",
40
- "url": "https://github.com/Chan-0312"
41
- },
42
- {
43
- "name": "BuvkB",
44
- "url": "https://github.com/BuvkB"
45
- },
46
- {
47
- "name": "masucc",
48
- "url": "https://github.com/masucc"
49
- },
50
- {
51
- "name": "cherryFloris",
52
- "url": "https://github.com/cherryFloris"
53
- },
54
- {
55
- "name": "geekyfoxlab",
56
- "url": "https://github.com/geekyfoxlab"
57
- },
58
- {
59
- "name": "grloper",
60
- "url": "https://github.com/grloper"
61
- },
62
- {
63
- "name": "baijian",
64
- "url": "https://github.com/baijian"
65
- },
66
- {
67
- "name": "lyzhu86",
68
- "url": "https://github.com/lyzhu86"
69
- },
70
- {
71
- "name": "Librazy",
72
- "url": "https://github.com/Librazy"
73
- },
74
- {
75
- "name": "Johnslee1207",
76
- "url": "https://github.com/johnslee1207-commits"
77
- },
78
- {
79
- "name": "Dong09",
80
- "url": "https://github.com/Dong09"
81
- },
82
- {
83
- "name": "wings1848",
84
- "url": "https://github.com/wings1848"
85
- },
86
- {
87
- "name": "Zhe (Phil) Yang",
88
- "url": "https://github.com/yangzhe1991"
89
- },
90
- {
91
- "name": "Biertolovisk",
92
- "url": "https://github.com/rosalynthompson32-a11y"
93
- },
94
- {
95
- "name": "Tianjun Cai",
96
- "url": "https://github.com/Pegasus02"
97
- }
98
- ],
99
29
  "license": "MIT",
100
30
  "type": "module",
101
31
  "repository": {
@@ -233,6 +233,14 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
233
233
  }
234
234
  return Object.freeze({
235
235
  channel,
236
+ describeAccount: (botId) => {
237
+ if (typeof coreController.describeDeliveryAccount !== 'function') {
238
+ const error = new Error('Verified account capability unavailable');
239
+ error.code = 'capability-unavailable';
240
+ throw error;
241
+ }
242
+ return coreController.describeDeliveryAccount(botId);
243
+ },
236
244
  ownsBot: (botId) => workspaces.has(botId),
237
245
  listBots: () => workspaces.listBotIds(),
238
246
  async listTargets(botId) {
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { normalizeDeliveryTarget } from './delivery-adapter.mjs';
2
3
 
3
4
  const BOT_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
@@ -18,6 +19,10 @@ const ADAPTER_METHODS = Object.freeze([
18
19
 
19
20
  const DELIVERY_ERROR_CODES = new Set([
20
21
  'bad-request',
22
+ 'account-unverified',
23
+ 'account-changed',
24
+ 'target-changed',
25
+ 'capability-unavailable',
21
26
  'unknown-bot',
22
27
  'unknown-target',
23
28
  'target-conflict',
@@ -290,6 +295,63 @@ export class DeliveryService {
290
295
  }
291
296
  }
292
297
 
298
+ #assertRegistered(registration) {
299
+ if (this.#adapters.get(registration.adapter.channel) !== registration) {
300
+ throw deliveryError('capability-unavailable');
301
+ }
302
+ }
303
+
304
+ async describeBot(botId) {
305
+ const id = botIdOf(botId);
306
+ const registration = await this.#checkedRegistrationFor(id);
307
+ const { adapter } = registration;
308
+ if (typeof adapter.describeAccount !== 'function') throw deliveryError('capability-unavailable');
309
+ this.#assertRegistered(registration);
310
+ try {
311
+ const account = await adapter.describeAccount(id);
312
+ this.#assertRegistered(registration);
313
+ return account;
314
+ } catch (error) { throw publicOperationError(error); }
315
+ }
316
+
317
+ async sendChecked(botId, targetId, text, { expectedFingerprint, expectedTargetDigest, signal, format = 'plain' } = {}) {
318
+ const id = botIdOf(botId);
319
+ const key = targetIdOf(targetId);
320
+ if (typeof text !== 'string' || !text.trim() || !['plain', 'markdown'].includes(format)
321
+ || !/^[a-f0-9]{64}$/.test(expectedFingerprint ?? '') || !/^[a-f0-9]{64}$/.test(expectedTargetDigest ?? '')) {
322
+ throw deliveryError('bad-request');
323
+ }
324
+ cancellation(signal);
325
+ const registration = await this.#checkedRegistrationFor(id);
326
+ const { adapter } = registration;
327
+ if (typeof adapter.describeAccount !== 'function') throw deliveryError('capability-unavailable');
328
+ this.#assertRegistered(registration);
329
+ try {
330
+ const candidates = await adapter.listTargets(id);
331
+ const saved = candidates.find((target) => target?.targetId === key);
332
+ if (!saved) throw deliveryError('unknown-target');
333
+ const target = normalizeDeliveryTarget(adapter.channel, {
334
+ targetId: saved.targetId, kind: saved.kind, route: structuredClone(saved.route),
335
+ });
336
+ const digest = createHash('sha256').update(JSON.stringify({ kind: target.kind,
337
+ route: Object.fromEntries(Object.entries(target.route).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) })).digest('hex');
338
+ if (digest !== expectedTargetDigest) throw deliveryError('target-changed');
339
+ const account = await adapter.describeAccount(id);
340
+ if (account?.version !== 1 || !account.capabilities?.includes('proactive-text-checked')) {
341
+ throw deliveryError('capability-unavailable');
342
+ }
343
+ if (account.account?.fingerprint !== expectedFingerprint) throw deliveryError('account-changed');
344
+ const beforeSend = () => {
345
+ cancellation(signal);
346
+ this.#assertRegistered(registration);
347
+ };
348
+ beforeSend();
349
+ await adapter.sendText(id, target, text, { signal, expectedFingerprint, beforeSend,
350
+ ...(format === 'markdown' ? { format } : {}) });
351
+ return { sent: true };
352
+ } catch (error) { throw publicOperationError(error); }
353
+ }
354
+
293
355
  async send(botId, targetIdOrDraft, text, { signal, format = 'plain' } = {}) {
294
356
  const id = botIdOf(botId);
295
357
  const targetKey = typeof targetIdOrDraft === 'string'
@@ -328,6 +390,19 @@ export class DeliveryService {
328
390
  }
329
391
  }
330
392
 
393
+ async #checkedRegistrationFor(botId) {
394
+ for (const registration of this.#adapters.values()) {
395
+ let ownsBot;
396
+ try { ownsBot = await registration.adapter.ownsBot(botId); }
397
+ catch (error) { throw publicOperationError(error); }
398
+ if (ownsBot) {
399
+ this.#assertRegistered(registration);
400
+ return registration;
401
+ }
402
+ }
403
+ throw deliveryError('unknown-bot', 'Unknown bot');
404
+ }
405
+
331
406
  async #adapterFor(botId) {
332
407
  for (const { adapter } of this.#adapters.values()) {
333
408
  let ownsBot;
@@ -125,33 +125,33 @@ export function installHostLanguage(ctx, config = {}, internals = {}) {
125
125
  config: config.language ?? process.env.DSH_IM_LANGUAGE,
126
126
  logger,
127
127
  });
128
- // Read an already-attached settings service synchronously: channels start
129
- // inside the same activation, and waiting for the injection callback would
130
- // let the first bot register its command menu in the previous language and
131
- // then need a second push. The injection below keeps it live afterwards and
132
- // covers a provider that attaches later.
133
- controller.observeSettings(
134
- () => ctx?.settings?.get?.(DSH_LOCALE_NAMESPACE)?.[DSH_LOCALE_PREFERENCE_FIELD],
135
- );
136
- const ready = Promise.resolve()
128
+ // Only the injected context may access settings. Waiting for its current
129
+ // lifecycle work does not wait for an absent provider to arrive.
130
+ let settingsBinding;
131
+ const readPreference = (settings) => typeof settings?.describe === 'function'
132
+ ? settings.describe().find((row) => row.ns === DSH_LOCALE_NAMESPACE)
133
+ ?.value?.[DSH_LOCALE_PREFERENCE_FIELD]
134
+ : settings?.get?.(DSH_LOCALE_NAMESPACE)?.[DSH_LOCALE_PREFERENCE_FIELD];
135
+ if (typeof ctx?.inject === 'function') {
136
+ settingsBinding = ctx.inject(['settings'], (settingsCtx) => {
137
+ controller.observeSettings(() => readPreference(settingsCtx.settings));
138
+ for (const event of ['settings/updated', 'settings/document-updated']) {
139
+ settingsCtx.on(event, (namespace) => {
140
+ if (namespace === DSH_LOCALE_NAMESPACE) controller.apply();
141
+ });
142
+ }
143
+ // Do not retain a reader bound to a withdrawn service/context.
144
+ return () => { controller.observeSettings(); };
145
+ });
146
+ }
147
+ const ready = Promise.resolve(settingsBinding)
137
148
  .then(() => store.load?.())
138
149
  .then(() => controller.apply(), (error) => {
139
150
  logger?.error?.(
140
- '[dsh-im] could not read the mirrored DSH interface language; falling back to Chinese',
151
+ '[dsh-im] could not initialize the DSH interface language; falling back to the available language',
141
152
  error,
142
153
  );
143
154
  return controller.snapshot();
144
155
  });
145
- if (typeof ctx?.inject === 'function') {
146
- ctx.inject(['settings'], (settingsCtx) => {
147
- controller.observeSettings(
148
- () => settingsCtx.settings?.get?.(DSH_LOCALE_NAMESPACE)?.[DSH_LOCALE_PREFERENCE_FIELD],
149
- );
150
- settingsCtx.on('settings/updated', (namespace) => {
151
- if (namespace !== DSH_LOCALE_NAMESPACE) return;
152
- controller.apply();
153
- });
154
- });
155
- }
156
156
  return Object.freeze({ ...controller, ready });
157
157
  }
@@ -91,6 +91,9 @@ export function createImHostPlugin(internals = {}) {
91
91
  const deliveryService = makeDeliveryService({ unavailableSessionSyncChannels });
92
92
  if (typeof ctx?.provide === 'function') {
93
93
  ctx.provide('dshIm', Object.freeze({
94
+ contractVersion: 1,
95
+ describeBot: (botId) => deliveryService.describeBot(botId),
96
+ sendChecked: (botId, targetId, text, options) => deliveryService.sendChecked(botId, targetId, text, options),
94
97
  send: (botId, targetId, text, options) => (
95
98
  deliveryService.send(botId, targetId, text, options)
96
99
  ),
@@ -240,6 +240,9 @@ class ModernHarnessApi {
240
240
  models: (request, signal) => rpcResult(request, () => this.#sessionModels(
241
241
  request.payload.sessionId, signal,
242
242
  )),
243
+ permissions: (request, signal) => rpcResult(request, () => this.#sessionPermissions(
244
+ request.payload.sessionId, signal,
245
+ )),
243
246
  selectModel: (request, signal) => rpcResult(request, () => this.#invoke(
244
247
  'session', 'selectModel', { request: request.payload }, signal,
245
248
  )),
@@ -414,6 +417,25 @@ class ModernHarnessApi {
414
417
  return this.#invoke('session', 'modelCatalog', {}, signal);
415
418
  }
416
419
 
420
+ async #sessionPermissions(sessionId, signal) {
421
+ const [catalog, projection] = await Promise.all([
422
+ this.#invoke('permissionPresets', 'catalog', {}, signal),
423
+ this.#invoke('session', 'projections', { request: { sessionId } }, signal),
424
+ ]);
425
+ if (projection === null) {
426
+ const error = new Error('Session not found');
427
+ error.failure = { code: 'session-not-found', message: error.message, details: {} };
428
+ throw error;
429
+ }
430
+ const currentValue = projection?.values?.permissions?.currentValue;
431
+ if (typeof currentValue !== 'string' || !Array.isArray(catalog?.options)) {
432
+ const error = new Error('Host permission presets are unavailable');
433
+ error.failure = { code: 'permissions-unavailable', message: error.message, details: {} };
434
+ throw error;
435
+ }
436
+ return { currentValue, options: catalog.options };
437
+ }
438
+
417
439
  async #sessionModels(sessionId, signal) {
418
440
  const [catalog, list] = await Promise.all([
419
441
  this.#modelCatalog(signal),
@@ -23,6 +23,7 @@ function sessionIdOf(session) {
23
23
 
24
24
  function userInputOrigin(scope, event) {
25
25
  const source = event?.data?.source;
26
+ if (source?.kind === 'schedule') return 'dsh';
26
27
  if (source?.kind !== 'user' || typeof source.rpcId !== 'string' || !source.rpcId) {
27
28
  return 'other';
28
29
  }
@@ -112,12 +113,9 @@ export function createSessionSyncCoordinator({ deliveryService, logger = console
112
113
  if (event.surfaceOp !== 'append') return;
113
114
  if (state.origin === 'unknown') state.origin = origin;
114
115
  if (state.origin !== 'dsh' || origin !== 'dsh') return;
115
- // The user echo stays as plain text even when the mirror owns the
116
- // turn: the card also quotes the question, but keeping the echo here
117
- // guarantees recipients are established so a failed mirror can still
118
- // fall back to the final-answer text delivery.
116
+ const scheduled = event.data?.source?.kind === 'schedule';
119
117
  const text = textFromHarnessContent(event.data?.content);
120
- if (!text) return;
118
+ if (!text && !scheduled) return;
121
119
 
122
120
  let targets;
123
121
  try {
@@ -126,17 +124,16 @@ export function createSessionSyncCoordinator({ deliveryService, logger = console
126
124
  logFailure('lookup', null, error);
127
125
  targets = [];
128
126
  }
129
- const successful = await deliver(
130
- sessionId,
131
- targets,
132
- `${DSH_USER_PREFIX}${text}`,
133
- 'user delivery',
134
- );
127
+ // Scheduled framing is internal: select its recipients without echoing it.
128
+ // Direct DSH input still requires a successful echo before the final answer.
129
+ const recipients = scheduled
130
+ ? new Map(targets.filter(validRecipient).map((target) => [recipientKey(target), target]))
131
+ : await deliver(sessionId, targets, `${DSH_USER_PREFIX}${text}`, 'user delivery');
135
132
  if (state.recipients === null) {
136
- state.recipients = successful;
133
+ state.recipients = recipients;
137
134
  } else {
138
135
  for (const key of [...state.recipients.keys()]) {
139
- if (!successful.has(key)) state.recipients.delete(key);
136
+ if (!recipients.has(key)) state.recipients.delete(key);
140
137
  }
141
138
  }
142
139
  return;
@@ -1,3 +1,5 @@
1
+ import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
2
+ import { randomUUID } from 'node:crypto';
1
3
  import QRCode from 'qrcode';
2
4
  import {
3
5
  conversationKey,
@@ -297,6 +299,7 @@ function printableText(value) {
297
299
  }
298
300
 
299
301
  function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}) {
302
+ if (isPermissionCommand(text)) return true;
300
303
  if (hasImages || hasFiles || typeof text !== 'string') return false;
301
304
  const command = text.trim();
302
305
  return MENU_COMMAND.test(command)
@@ -1233,6 +1236,7 @@ export class FeishuHarnessBridge {
1233
1236
  return processing;
1234
1237
  }
1235
1238
  const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
1239
+ : isPermissionCommand(commandText) ? runPermissionCommand
1236
1240
  : hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
1237
1241
  ? runControlCommand
1238
1242
  : (isModelCommand(commandText)
@@ -1689,6 +1693,15 @@ export class FeishuHarnessBridge {
1689
1693
  await this.#handleRepairCommand(event, commandText);
1690
1694
  return;
1691
1695
  }
1696
+ if (isPermissionCommand(text)) {
1697
+ const result = await runPermissionCommand(text, this.#harness, this.#state, key, {
1698
+ signal: this.#signal, hasImages, hasFiles,
1699
+ });
1700
+ for (const reply of result.messages) {
1701
+ await this.#send(event.message.chat_id, reply, { replyTo: event.message.message_id });
1702
+ }
1703
+ return;
1704
+ }
1692
1705
  if (commandText === '/help') {
1693
1706
  await this.#send(event.message.chat_id, menuHelpText(), { replyTo: event.message.message_id });
1694
1707
  return;
@@ -2307,7 +2320,9 @@ export class FeishuHarnessBridge {
2307
2320
  // A used card is recent even if it was first created long ago.
2308
2321
  this.#cardKeys.delete(messageId);
2309
2322
  this.#cardKeys.set(messageId, route);
2310
- const entry = { ...route, messageId, selections: multiValues, operatorOpenId };
2323
+ const newSessionActionId = nonEmptyString(actionValue.newSessionActionId);
2324
+ if (resolvedAction === 'new' && (!route.menuContext || !newSessionActionId)) return Promise.resolve();
2325
+ const entry = { ...route, messageId, selections: multiValues, operatorOpenId, newSessionActionId };
2311
2326
  const source = nonEmptyString(actionValue.source);
2312
2327
  const formText = nonEmptyString(formValue.steer_text);
2313
2328
  const eventId = nonEmptyString(event?.event_id)
@@ -2316,6 +2331,7 @@ export class FeishuHarnessBridge {
2316
2331
  ?? nonEmptyString(event?.header?.uuid);
2317
2332
  const identity = JSON.stringify({
2318
2333
  messageId,
2334
+ newSessionActionId,
2319
2335
  resolvedAction,
2320
2336
  option,
2321
2337
  multiValues,
@@ -2517,6 +2533,8 @@ export class FeishuHarnessBridge {
2517
2533
  sessionLimit = null,
2518
2534
  selections = [],
2519
2535
  actor = null,
2536
+ menuContext = null,
2537
+ newSessionActionId = null,
2520
2538
  }) {
2521
2539
  // Confirmations triggered by a card interaction stay anchored to the
2522
2540
  // card's message so they land inside the same Feishu topic.
@@ -2666,6 +2684,10 @@ export class FeishuHarnessBridge {
2666
2684
  return;
2667
2685
  }
2668
2686
  if (action === 'new') {
2687
+ if (menuContext || newSessionActionId) {
2688
+ await this.#handleNewSessionMenu({ key, chatId, messageId, menuContext, newSessionActionId });
2689
+ return;
2690
+ }
2669
2691
  if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
2670
2692
  await reply(t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
2671
2693
  return;
@@ -2799,6 +2821,61 @@ export class FeishuHarnessBridge {
2799
2821
  }
2800
2822
  }
2801
2823
 
2824
+ async #handleNewSessionMenu({ key, chatId, messageId, menuContext, newSessionActionId }) {
2825
+ // The generation belongs to this rendered menu, not the whole conversation.
2826
+ // Check inside the queue as well as consuming the action before any await:
2827
+ // distinct provider events and late callbacks must not clear a later session.
2828
+ const action = menuContext?.newSessionAction;
2829
+ if (!action || this.#cardKeys.get(messageId)?.menuContext !== menuContext
2830
+ || action.id !== newSessionActionId
2831
+ || !['idle', 'failed'].includes(action.status)) return;
2832
+
2833
+ action.status = 'processing';
2834
+ delete action.message;
2835
+ await this.#patchCardMessage(chatId, messageId, menuCard(menuContext));
2836
+
2837
+ const finish = async (status, message = null) => {
2838
+ if (menuContext.currentSession && !this.#state.sessionFor(key)) {
2839
+ menuContext.currentSession = null;
2840
+ // The old menu may show a session-specific model. After unbinding,
2841
+ // refresh the bot default just as opening a fresh menu would.
2842
+ try {
2843
+ menuContext.modelCatalog = await this.#harness.listModels({ signal: this.#cardDataSignal() });
2844
+ } catch {
2845
+ menuContext.modelCatalog = null;
2846
+ }
2847
+ }
2848
+ menuContext.newSessionAction = {
2849
+ id: status === 'failed' ? randomUUID() : action.id,
2850
+ status,
2851
+ message,
2852
+ };
2853
+ const patched = await this.#patchCardMessage(chatId, messageId, menuCard(menuContext));
2854
+ if (!patched) {
2855
+ await this.#send(chatId, t('新会话操作的卡片状态未能更新,请发送 /m 重新打开菜单。'), { replyTo: messageId })
2856
+ .catch((error) => this.#logger.warn?.('[dsh-feishu] menu state notice failed:', error?.message));
2857
+ }
2858
+ };
2859
+ // A task can start while Feishu is updating the processing card.
2860
+ if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
2861
+ const message = t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。');
2862
+ await finish('failed', message);
2863
+ await this.#send(chatId, message, { replyTo: messageId });
2864
+ return;
2865
+ }
2866
+ try {
2867
+ await this.#state.clearSession(key);
2868
+ } catch (error) {
2869
+ // A persistence error can still leave the in-memory binding cleared.
2870
+ await finish('failed', safeErrorText(error));
2871
+ throw error;
2872
+ }
2873
+ await finish('success');
2874
+ // A receipt delivery failure must not turn a completed reset into a retry.
2875
+ await this.#send(chatId, t('已开启全新 Harness 会话。'), { replyTo: messageId })
2876
+ .catch((error) => this.#logger.warn?.('[dsh-feishu] new session receipt failed:', error?.message));
2877
+ }
2878
+
2802
2879
  #rememberMenu(key, menu) {
2803
2880
  if (this.#menus.size >= MAX_TRACKED_MENUS) {
2804
2881
  const oldest = this.#menus.keys().next().value;
@@ -3002,6 +3079,7 @@ export class FeishuHarnessBridge {
3002
3079
  key: options.key,
3003
3080
  chatId,
3004
3081
  conversationWorkspace: options.conversationWorkspace,
3082
+ menuContext: options.menuContext ?? null,
3005
3083
  sessionWorkspace: typeof options.sessionWorkspace === 'string' && options.sessionWorkspace
3006
3084
  ? options.sessionWorkspace
3007
3085
  : null,
@@ -3213,14 +3291,16 @@ export class FeishuHarnessBridge {
3213
3291
  }
3214
3292
  const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
3215
3293
  this.#rememberMenu(key, { kind: 'menu', chatId });
3294
+ const menuContext = {
3295
+ workspaces, currentWorkspace,
3296
+ currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
3297
+ sessions, archiveVisible, presetCatalog, modelCatalog,
3298
+ newSessionAction: { id: randomUUID(), status: 'idle' },
3299
+ };
3216
3300
  await this.#sendCard(
3217
3301
  chatId,
3218
- menuCard({
3219
- workspaces, currentWorkspace,
3220
- currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
3221
- sessions, archiveVisible, presetCatalog, modelCatalog,
3222
- }),
3223
- { key, updateMessageId, replyTo, conversationWorkspace },
3302
+ menuCard(menuContext),
3303
+ { key, updateMessageId, replyTo, conversationWorkspace, menuContext },
3224
3304
  );
3225
3305
  }
3226
3306
 
@@ -123,6 +123,7 @@ function backButton() {
123
123
  * - archiveVisible: boolean (归档显隐开关当前值)
124
124
  * - presetCatalog: object|null (预设目录, {items,defaultId,_currentId})
125
125
  * - modelCatalog: object|null (模型目录, {groups,current})
126
+ * - newSessionAction: {id,status,message?}|null (新会话按钮的执行状态)
126
127
  * Number fallback: 1=续写 2=新会话 3=会话列表 4=状态
127
128
  * 5=补全权限 6=帮助.
128
129
  */
@@ -135,6 +136,7 @@ export function menuCard(ctx) {
135
136
  archiveVisible = false,
136
137
  presetCatalog = null,
137
138
  modelCatalog = null,
139
+ newSessionAction = null,
138
140
  } = ctx || {};
139
141
 
140
142
  // currentSession 提供 id/title;为兼容旧调用,也接受 currentSessionTitle
@@ -146,6 +148,19 @@ export function menuCard(ctx) {
146
148
  const hasWorkspaces = Array.isArray(workspaces) && workspaces.length > 0;
147
149
  const elements = [];
148
150
 
151
+ let newSessionLabel = t('🆕 新会话');
152
+ if (newSessionAction?.status === 'processing') {
153
+ newSessionLabel = t('正在开启…');
154
+ elements.push({ tag: 'div', text: plainText(t('正在开启新会话…')) });
155
+ } else if (newSessionAction?.status === 'success') {
156
+ newSessionLabel = t('已执行:新会话');
157
+ elements.push({ tag: 'div', text: plainText(t('已执行:新会话。发送消息即可开始。')) });
158
+ } else if (newSessionAction?.status === 'failed') {
159
+ elements.push({ tag: 'div', text: plainText(t('新会话操作失败:{message}', {
160
+ message: newSessionAction.message || t('卡片操作失败,请稍后重试。'),
161
+ })) });
162
+ }
163
+
149
164
  // ── 设置区 ──────────────────────────────────────────────────
150
165
  elements.push({ tag: 'div', text: markdown(t('**设置**')) });
151
166
 
@@ -270,7 +285,15 @@ export function menuCard(ctx) {
270
285
  });
271
286
 
272
287
  // 新会话 + 全部会话按钮
273
- elements.push(buttonPair(t('🆕 新会话'), 'new', t('📋 会话/关注'), 'sessions'));
288
+ const sessionButtons = buttonPair(newSessionLabel, 'new', t('📋 会话/关注'), 'sessions');
289
+ const newSessionButton = sessionButtons.columns[0].elements[0];
290
+ if (newSessionAction?.id) {
291
+ newSessionButton.behaviors[0].value.newSessionActionId = newSessionAction.id;
292
+ }
293
+ if (newSessionAction?.status === 'processing' || newSessionAction?.status === 'success') {
294
+ newSessionButton.disabled = true;
295
+ }
296
+ elements.push(sessionButtons);
274
297
  if (!hasSessions && !hasWorkspaces) {
275
298
  elements.push(button(t('🗂 工作区列表'), 'workspaces'));
276
299
  }
@@ -512,6 +535,9 @@ export function menuHelpText() {
512
535
  '/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
513
536
  '纯数字 ID:/preset id:<ID>',
514
537
  '/preset --default 跟随 Host 默认',
538
+ '/permissionlist 按序号列出权限档位',
539
+ '/permission [序号或完整ID] 查看或切换当前会话权限',
540
+ '纯数字 ID:/permission id:<ID>',
515
541
  '/models 列出模型',
516
542
  '/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
517
543
  '/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
@@ -596,6 +622,8 @@ export function helpCard(extraTextLines = []) {
596
622
  { tag: 'hr' },
597
623
  { tag: 'div', text: markdown([
598
624
  t(HELP_TEXT_COMMANDS),
625
+ t('`/permissionlist` — 按序号列出权限档位'),
626
+ t('`/permission [序号/ID]` — 查看或切换当前会话权限'),
599
627
  t('/ws、/wsl、/workspaces 工作区命令别名'),
600
628
  t('`/version` — 查看插件版本'),
601
629
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),