@xmanrui/dsh-im 4.31.0 → 4.33.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/locale/en.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "meta": {
3
+ "description": "Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH."
4
+ }
5
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "meta": {
3
+ "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。"
4
+ }
5
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.31.0",
3
+ "version": "4.33.0",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
- "description": "把十二种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect twelve IM channels and a public AI Office to a local DeepSeek Harness.",
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": [
7
7
  "deepseek-harness",
8
8
  "dsh",
@@ -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": {
@@ -116,13 +46,15 @@
116
46
  "exports": {
117
47
  ".": "./lib/index.js",
118
48
  "./client": "./lib/client.js",
119
- "./package.json": "./package.json"
49
+ "./package.json": "./package.json",
50
+ "./locale/*.json": "./locale/*.json"
120
51
  },
121
52
  "files": [
122
53
  "assets",
123
54
  "bin",
124
55
  "cordis.patch.yml",
125
56
  "lib",
57
+ "locale/*.json",
126
58
  "plugin-src",
127
59
  "scripts/verify-interface-language.mjs",
128
60
  "scripts/verify-injected-context.mjs",
@@ -42,6 +42,8 @@ const EN = Object.freeze({
42
42
  '无需安装第三方服务;请按指引授予 macOS Messages 访问权限。': 'No third-party service is required; follow the guide to grant macOS Messages access.',
43
43
  'macOS Messages 权限': 'macOS Messages permissions',
44
44
  'IM机器人': 'IM bots',
45
+ '打开 IM机器人': 'Open IM bots',
46
+ '返回插件详情': 'Back to plugin details',
45
47
  'IM机器人设置': 'IM bot settings',
46
48
  'IM 面板加载失败': 'Could not load the IM panel',
47
49
  '更多机器人设置': 'More bot settings',
@@ -83,6 +83,7 @@ import { installSessionChannelLogos } from './session-channel-logos.js';
83
83
  import { UpdatePanel, UPDATE_RPC_CHANNEL } from './update-panel.js';
84
84
  import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
85
85
  import { IMPanelErrorBoundary } from './panel-error-boundary.js';
86
+ import { installImPluginPage } from './plugin-page.js';
86
87
 
87
88
  export const name = 'im-settings';
88
89
  export const inject = ['slots', 'connection', 'locale', 'workspaces'];
@@ -566,6 +567,8 @@ export function apply(ctx) {
566
567
  preferredSectionId: props.preferredSectionId,
567
568
  });
568
569
 
570
+ installImPluginPage(ctx, { packageName: manifest.name, renderPanel: buildPanelElement });
571
+
569
572
  ctx.effect(() => {
570
573
  let disposed = false;
571
574
  let stopSettings = null;
@@ -0,0 +1,51 @@
1
+ import * as React from 'react';
2
+ import { IM_LOCALE_NAMESPACE } from './i18n.js';
3
+
4
+ export const IM_MAIN_PANEL_ID = 'xmanrui-dsh-im';
5
+ const h = React.createElement;
6
+
7
+ function OpenImAction({ subject, packageName, onOpen, t }) {
8
+ if (subject?.kind !== 'bundle' && subject?.kind !== 'row') return null;
9
+ if (subject.pkg?.name !== packageName) return null;
10
+ if (subject.kind === 'row' && subject.row?.moduleName !== packageName) return null;
11
+ return h('button', {
12
+ type: 'button', className: 'dim-pluginOpen', onClick: onOpen,
13
+ }, t('打开 IM机器人'));
14
+ }
15
+
16
+ function ImPluginPage({ renderPanel, onBack, t }) {
17
+ return h('div', { className: 'dim-pluginPage' },
18
+ h('div', { className: 'dim-pluginPageContent' },
19
+ h('button', { type: 'button', className: 'dim-pluginBack', onClick: onBack },
20
+ h('span', { 'aria-hidden': true }, '‹'), t('返回插件详情')),
21
+ renderPanel()));
22
+ }
23
+
24
+ // Optional services and slots keep the existing settings entry working on
25
+ // older DSH versions. Reuse the same panel through public navigation APIs.
26
+ export function installImPluginPage(ctx, { packageName, renderPanel }) {
27
+ if (typeof ctx.inject !== 'function') return;
28
+ ctx.inject(['layout', 'pluginNavigation'], (pageCtx) =>
29
+ pageCtx.slots.inject('main', () => {
30
+ const unregisterPage = pageCtx.slots.register({
31
+ name: 'main', key: IM_MAIN_PANEL_ID, locale: IM_LOCALE_NAMESPACE,
32
+ inject: () => ({
33
+ renderPanel,
34
+ onBack: () => pageCtx.pluginNavigation.openBundle(packageName),
35
+ }),
36
+ }, ImPluginPage);
37
+ const stopActions = pageCtx.slots.inject('plugins.detail.actions', () =>
38
+ pageCtx.slots.register({
39
+ name: 'plugins.detail.actions', id: 'xmanrui-dsh-im-open',
40
+ order: 20, locale: IM_LOCALE_NAMESPACE,
41
+ inject: () => ({
42
+ packageName,
43
+ onOpen: () => pageCtx.layout.selectPanel(IM_MAIN_PANEL_ID),
44
+ }),
45
+ }, OpenImAction));
46
+ return () => {
47
+ stopActions();
48
+ unregisterPage();
49
+ };
50
+ }));
51
+ }
@@ -1,6 +1,15 @@
1
1
  export const IM_STYLE_ID = 'xmanrui-dsh-im-settings';
2
2
 
3
3
  const CSS = String.raw`
4
+ .dim-pluginOpen { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 7px 14px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; line-height: 20px; cursor: pointer; }
5
+ .dim-pluginOpen:hover { background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
6
+ .dim-pluginOpen:focus-visible, .dim-pluginBack:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 3px; }
7
+ .dim-pluginPage { height: 100%; min-height: 0; overflow: auto; box-sizing: border-box; padding: 24px clamp(16px, 4vw, 48px) 32px; color: var(--dsw-alias-label-primary, #1f2329); }
8
+ .dim-pluginPageContent { max-width: 1080px; margin: 0 auto; }
9
+ .dim-pluginBack { display: inline-flex; align-items: center; gap: 8px; min-height: 36px; margin: 0 0 24px; padding: 6px 8px; border: 0; border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 13px; line-height: 20px; cursor: pointer; }
10
+ .dim-pluginBack:hover { color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
11
+ .dim-pluginBack > span { font-size: 22px; line-height: 20px; }
12
+ @media (pointer: coarse) { .dim-pluginOpen, .dim-pluginBack { min-height: 44px; } }
4
13
  .dim-connectionDiagnostic { min-width: 0; width: 100%; color: var(--dsw-alias-label-secondary, #646a73); overflow-wrap: anywhere; font-size: 13px; line-height: 1.6; }
5
14
  .dim-connectionDiagnostic[data-warning="true"] { color: var(--dsw-alias-state-warn-primary, #d97706); }
6
15
  .dim-connectionDiagnostic p { margin: 4px 0; }
@@ -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
  ),
@@ -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,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import QRCode from 'qrcode';
2
3
  import {
3
4
  conversationKey,
@@ -2307,7 +2308,9 @@ export class FeishuHarnessBridge {
2307
2308
  // A used card is recent even if it was first created long ago.
2308
2309
  this.#cardKeys.delete(messageId);
2309
2310
  this.#cardKeys.set(messageId, route);
2310
- const entry = { ...route, messageId, selections: multiValues, operatorOpenId };
2311
+ const newSessionActionId = nonEmptyString(actionValue.newSessionActionId);
2312
+ if (resolvedAction === 'new' && (!route.menuContext || !newSessionActionId)) return Promise.resolve();
2313
+ const entry = { ...route, messageId, selections: multiValues, operatorOpenId, newSessionActionId };
2311
2314
  const source = nonEmptyString(actionValue.source);
2312
2315
  const formText = nonEmptyString(formValue.steer_text);
2313
2316
  const eventId = nonEmptyString(event?.event_id)
@@ -2316,6 +2319,7 @@ export class FeishuHarnessBridge {
2316
2319
  ?? nonEmptyString(event?.header?.uuid);
2317
2320
  const identity = JSON.stringify({
2318
2321
  messageId,
2322
+ newSessionActionId,
2319
2323
  resolvedAction,
2320
2324
  option,
2321
2325
  multiValues,
@@ -2517,6 +2521,8 @@ export class FeishuHarnessBridge {
2517
2521
  sessionLimit = null,
2518
2522
  selections = [],
2519
2523
  actor = null,
2524
+ menuContext = null,
2525
+ newSessionActionId = null,
2520
2526
  }) {
2521
2527
  // Confirmations triggered by a card interaction stay anchored to the
2522
2528
  // card's message so they land inside the same Feishu topic.
@@ -2666,6 +2672,10 @@ export class FeishuHarnessBridge {
2666
2672
  return;
2667
2673
  }
2668
2674
  if (action === 'new') {
2675
+ if (menuContext || newSessionActionId) {
2676
+ await this.#handleNewSessionMenu({ key, chatId, messageId, menuContext, newSessionActionId });
2677
+ return;
2678
+ }
2669
2679
  if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
2670
2680
  await reply(t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
2671
2681
  return;
@@ -2799,6 +2809,61 @@ export class FeishuHarnessBridge {
2799
2809
  }
2800
2810
  }
2801
2811
 
2812
+ async #handleNewSessionMenu({ key, chatId, messageId, menuContext, newSessionActionId }) {
2813
+ // The generation belongs to this rendered menu, not the whole conversation.
2814
+ // Check inside the queue as well as consuming the action before any await:
2815
+ // distinct provider events and late callbacks must not clear a later session.
2816
+ const action = menuContext?.newSessionAction;
2817
+ if (!action || this.#cardKeys.get(messageId)?.menuContext !== menuContext
2818
+ || action.id !== newSessionActionId
2819
+ || !['idle', 'failed'].includes(action.status)) return;
2820
+
2821
+ action.status = 'processing';
2822
+ delete action.message;
2823
+ await this.#patchCardMessage(chatId, messageId, menuCard(menuContext));
2824
+
2825
+ const finish = async (status, message = null) => {
2826
+ if (menuContext.currentSession && !this.#state.sessionFor(key)) {
2827
+ menuContext.currentSession = null;
2828
+ // The old menu may show a session-specific model. After unbinding,
2829
+ // refresh the bot default just as opening a fresh menu would.
2830
+ try {
2831
+ menuContext.modelCatalog = await this.#harness.listModels({ signal: this.#cardDataSignal() });
2832
+ } catch {
2833
+ menuContext.modelCatalog = null;
2834
+ }
2835
+ }
2836
+ menuContext.newSessionAction = {
2837
+ id: status === 'failed' ? randomUUID() : action.id,
2838
+ status,
2839
+ message,
2840
+ };
2841
+ const patched = await this.#patchCardMessage(chatId, messageId, menuCard(menuContext));
2842
+ if (!patched) {
2843
+ await this.#send(chatId, t('新会话操作的卡片状态未能更新,请发送 /m 重新打开菜单。'), { replyTo: messageId })
2844
+ .catch((error) => this.#logger.warn?.('[dsh-feishu] menu state notice failed:', error?.message));
2845
+ }
2846
+ };
2847
+ // A task can start while Feishu is updating the processing card.
2848
+ if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
2849
+ const message = t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。');
2850
+ await finish('failed', message);
2851
+ await this.#send(chatId, message, { replyTo: messageId });
2852
+ return;
2853
+ }
2854
+ try {
2855
+ await this.#state.clearSession(key);
2856
+ } catch (error) {
2857
+ // A persistence error can still leave the in-memory binding cleared.
2858
+ await finish('failed', safeErrorText(error));
2859
+ throw error;
2860
+ }
2861
+ await finish('success');
2862
+ // A receipt delivery failure must not turn a completed reset into a retry.
2863
+ await this.#send(chatId, t('已开启全新 Harness 会话。'), { replyTo: messageId })
2864
+ .catch((error) => this.#logger.warn?.('[dsh-feishu] new session receipt failed:', error?.message));
2865
+ }
2866
+
2802
2867
  #rememberMenu(key, menu) {
2803
2868
  if (this.#menus.size >= MAX_TRACKED_MENUS) {
2804
2869
  const oldest = this.#menus.keys().next().value;
@@ -3002,6 +3067,7 @@ export class FeishuHarnessBridge {
3002
3067
  key: options.key,
3003
3068
  chatId,
3004
3069
  conversationWorkspace: options.conversationWorkspace,
3070
+ menuContext: options.menuContext ?? null,
3005
3071
  sessionWorkspace: typeof options.sessionWorkspace === 'string' && options.sessionWorkspace
3006
3072
  ? options.sessionWorkspace
3007
3073
  : null,
@@ -3213,14 +3279,16 @@ export class FeishuHarnessBridge {
3213
3279
  }
3214
3280
  const archiveVisible = this.#state?.includesArchivedSessions?.() ?? false;
3215
3281
  this.#rememberMenu(key, { kind: 'menu', chatId });
3282
+ const menuContext = {
3283
+ workspaces, currentWorkspace,
3284
+ currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
3285
+ sessions, archiveVisible, presetCatalog, modelCatalog,
3286
+ newSessionAction: { id: randomUUID(), status: 'idle' },
3287
+ };
3216
3288
  await this.#sendCard(
3217
3289
  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 },
3290
+ menuCard(menuContext),
3291
+ { key, updateMessageId, replyTo, conversationWorkspace, menuContext },
3224
3292
  );
3225
3293
  }
3226
3294
 
@@ -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
  }