@xmanrui/dsh-im 4.9.1 → 4.11.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.
Files changed (72) hide show
  1. package/PROACTIVE_DELIVERY.en.md +23 -1
  2. package/PROACTIVE_DELIVERY.md +23 -1
  3. package/README.en.md +5 -1
  4. package/README.md +6 -2
  5. package/lib/client.js +2077 -673
  6. package/lib/index.js +277 -272
  7. package/package.json +13 -1
  8. package/plugin-src/client/channel-card-meta.js +9 -1
  9. package/plugin-src/client/channels/dingtalk/api.js +4 -0
  10. package/plugin-src/client/channels/dingtalk/index.js +25 -2
  11. package/plugin-src/client/channels/feishu/api.js +6 -0
  12. package/plugin-src/client/channels/feishu/group-settings.js +581 -0
  13. package/plugin-src/client/channels/feishu/index.js +26 -150
  14. package/plugin-src/client/channels/qq/api.js +4 -0
  15. package/plugin-src/client/channels/qq/index.js +25 -2
  16. package/plugin-src/client/channels/shared/token-api.js +4 -0
  17. package/plugin-src/client/channels/shared/token-channel.js +25 -3
  18. package/plugin-src/client/channels/wecom/api.js +4 -0
  19. package/plugin-src/client/channels/wecom/index.js +25 -2
  20. package/plugin-src/client/channels/weixin/api.js +4 -0
  21. package/plugin-src/client/channels/weixin/index.js +25 -2
  22. package/plugin-src/client/channels/whatsapp/api.js +4 -0
  23. package/plugin-src/client/channels/whatsapp/index.js +24 -2
  24. package/plugin-src/client/delivery-settings.js +73 -5
  25. package/plugin-src/client/global-settings.js +341 -0
  26. package/plugin-src/client/i18n.js +49 -0
  27. package/plugin-src/client/index.js +47 -6
  28. package/plugin-src/client/model-setting.js +104 -0
  29. package/plugin-src/client/styles.js +99 -0
  30. package/plugin-src/host/channels/dingtalk/production.mjs +15 -0
  31. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  32. package/plugin-src/host/channels/feishu/production.mjs +17 -0
  33. package/plugin-src/host/channels/feishu/rpc.mjs +32 -0
  34. package/plugin-src/host/channels/qq/production.mjs +15 -0
  35. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  36. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +19 -0
  37. package/plugin-src/host/channels/shared/production.mjs +15 -0
  38. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  39. package/plugin-src/host/channels/shared/workspace-rpc.mjs +2 -0
  40. package/plugin-src/host/channels/slack/production.mjs +15 -0
  41. package/plugin-src/host/channels/slack/rpc.mjs +9 -0
  42. package/plugin-src/host/channels/wecom/production.mjs +15 -0
  43. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  44. package/plugin-src/host/channels/weixin/production.mjs +15 -0
  45. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  46. package/plugin-src/host/channels/whatsapp/production.mjs +15 -0
  47. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  48. package/plugin-src/host/delivery-adapter.mjs +142 -3
  49. package/plugin-src/host/delivery-rpc.mjs +12 -0
  50. package/plugin-src/host/delivery-service.mjs +125 -3
  51. package/plugin-src/host/delivery-suggestions.mjs +46 -0
  52. package/plugin-src/host/harness-session-coordinator.mjs +32 -10
  53. package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
  54. package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
  55. package/plugin-src/host/inbound-ttl-service.mjs +179 -0
  56. package/plugin-src/host/index.mjs +26 -1
  57. package/plugin-src/host/modern-harness-api.mjs +58 -2
  58. package/plugin-src/host/session-sync-coordinator.mjs +217 -0
  59. package/scripts/verify-package.mjs +12 -6
  60. package/src/channels/feishu/bridge.mjs +164 -4
  61. package/src/channels/feishu/feishu-channel.mjs +45 -10
  62. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  63. package/src/channels/feishu/message-utils.mjs +13 -0
  64. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  65. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  66. package/src/channels/feishu/state-store.mjs +25 -0
  67. package/src/channels/shared/bot-workspace-store.mjs +291 -38
  68. package/src/channels/shared/harness-client.mjs +124 -43
  69. package/src/channels/shared/inbound-file.mjs +117 -2
  70. package/src/channels/shared/inbound-ttl-store.mjs +110 -0
  71. package/src/channels/shared/inbound-ttl.mjs +22 -0
  72. package/src/channels/shared/model-setting.mjs +110 -0
@@ -13,6 +13,8 @@ import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
13
13
  import { installDeliveryRpc } from './delivery-rpc.mjs';
14
14
  import { installDeliveryHttp } from './delivery-http.mjs';
15
15
  import { createDeliveryService } from './delivery-service.mjs';
16
+ import { installInboundTtlRpc } from './inbound-ttl-rpc.mjs';
17
+ import { installSessionSyncCoordinator } from './session-sync-coordinator.mjs';
16
18
  import { installUpdateRpc } from './update-rpc.mjs';
17
19
 
18
20
  export const name = 'dsh-im-host';
@@ -32,8 +34,11 @@ function channelConfig(config, name, deliveryService) {
32
34
 
33
35
  export function createImHostPlugin(internals = {}) {
34
36
  const startUpdate = internals.installUpdateRpc ?? installUpdateRpc;
37
+ const startInboundTtl = internals.installInboundTtlRpc ?? installInboundTtlRpc;
35
38
  const startDelivery = internals.installDeliveryRpc ?? installDeliveryRpc;
36
39
  const startDeliveryHttp = internals.installDeliveryHttp ?? installDeliveryHttp;
40
+ const startSessionSync = internals.installSessionSyncCoordinator
41
+ ?? installSessionSyncCoordinator;
37
42
  const makeDeliveryService = internals.createDeliveryService ?? createDeliveryService;
38
43
  const startFeishu = internals.applyFeishu ?? applyFeishu;
39
44
  const startWeixin = internals.applyWeixin ?? applyWeixin;
@@ -61,13 +66,18 @@ export function createImHostPlugin(internals = {}) {
61
66
  name,
62
67
  inject,
63
68
  async apply(ctx, config = {}) {
64
- const deliveryService = makeDeliveryService();
69
+ const unavailableSessionSyncChannels = channels
70
+ .map(([channel]) => channel)
71
+ .filter((channel) => channel !== 'office'
72
+ && config[channel]?.harnessBaseUrl !== undefined);
73
+ const deliveryService = makeDeliveryService({ unavailableSessionSyncChannels });
65
74
  if (typeof ctx?.provide === 'function') {
66
75
  ctx.provide('dshIm', Object.freeze({
67
76
  send: (botId, targetId, text, options) => (
68
77
  deliveryService.send(botId, targetId, text, options)
69
78
  ),
70
79
  listTargets: async (botId) => (await deliveryService.listTargets(botId)).targets,
80
+ listBots: () => deliveryService.listBots(),
71
81
  }));
72
82
  }
73
83
  const activate = async (readyCtx) => {
@@ -109,6 +119,11 @@ export function createImHostPlugin(internals = {}) {
109
119
  } catch (error) {
110
120
  logger.error?.('[dsh-im] failed to activate update management; continuing with channels', error);
111
121
  }
122
+ try {
123
+ startInboundTtl(ctx, { config });
124
+ } catch (error) {
125
+ logger.error?.('[dsh-im] failed to activate inbound TTL settings; continuing with channels', error);
126
+ }
112
127
  try {
113
128
  startDelivery(ctx, deliveryService, { authority: config.rpcAuthority });
114
129
  } catch (error) {
@@ -127,6 +142,16 @@ export function createImHostPlugin(internals = {}) {
127
142
  if (failures.length === channels.length) {
128
143
  throw new AggregateError(failures, 'dsh-im failed to activate every channel');
129
144
  }
145
+ if (typeof ctx?.on === 'function') {
146
+ try {
147
+ startSessionSync(ctx, deliveryService, {
148
+ logger,
149
+ inputScope: ctx.root ?? ctx,
150
+ });
151
+ } catch (error) {
152
+ logger.error?.('[dsh-im] failed to activate Session sync; continuing with channels', error);
153
+ }
154
+ }
130
155
  }
131
156
  }
132
157
 
@@ -200,6 +200,7 @@ class ModernHarnessApi {
200
200
  #pendingQuestions = new Map();
201
201
  #pendingApprovals = new Map();
202
202
  #sessionCursors = new Map();
203
+ #assistantStreams = new Map();
203
204
  #disposers = [];
204
205
  #disposed = false;
205
206
 
@@ -261,6 +262,13 @@ class ModernHarnessApi {
261
262
  if (Number.isSafeInteger(event.seq)) this.#rememberCursor(sessionId, event.seq);
262
263
  this.#broadcast({ type: 'session/event', sessionId, event });
263
264
  }, { global: true }));
265
+ this.#disposers.push(ctx.on('agent/assistant-stream', ({ agent, frame }) => {
266
+ this.#acceptAssistantStream(agent, frame);
267
+ }, { global: true }));
268
+ this.#disposers.push(ctx.on('agent/disposed', ({ agent }) => {
269
+ const sessionId = agent?.session?.id;
270
+ if (typeof sessionId === 'string') this.#assistantStreams.delete(sessionId);
271
+ }, { global: true }));
264
272
  this.#disposers.push(ctx.on(
265
273
  'approval/request',
266
274
  (request, next) => this.#requestApproval(request, next),
@@ -328,7 +336,7 @@ class ModernHarnessApi {
328
336
  cursor = this.#sessionCursors.get(sessionId) ?? snapshot.cursor;
329
337
  if (beforeSeq === undefined && cursor === snapshot.cursor) {
330
338
  return {
331
- events: historyEntries(snapshot.records),
339
+ events: this.#withAssistantStream(sessionId, historyEntries(snapshot.records)),
332
340
  hasMore: snapshot.hasMore === true,
333
341
  ...(snapshot.projections === undefined ? {} : { projections: snapshot.projections }),
334
342
  };
@@ -342,7 +350,54 @@ class ModernHarnessApi {
342
350
  ...(beforeSeq === undefined ? {} : { beforeSeq }),
343
351
  },
344
352
  }, signal);
345
- return { events: historyEntries(page.records), hasMore: page.hasMore === true };
353
+ return {
354
+ events: beforeSeq === undefined
355
+ ? this.#withAssistantStream(sessionId, historyEntries(page.records))
356
+ : historyEntries(page.records),
357
+ hasMore: page.hasMore === true,
358
+ };
359
+ }
360
+
361
+ #acceptAssistantStream(agent, frame) {
362
+ const sessionId = agent?.session?.id;
363
+ if (typeof sessionId !== 'string' || !frame || typeof frame !== 'object') return;
364
+ if (frame.type === 'start') {
365
+ const nextSeq = agent?.session?.seq;
366
+ const startedAfterSeq = Number.isSafeInteger(nextSeq) && nextSeq >= 0
367
+ ? nextSeq - 1
368
+ : this.#sessionCursors.get(sessionId) ?? -1;
369
+ this.#assistantStreams.set(sessionId, {
370
+ attemptId: frame.attemptId,
371
+ turn: frame.turn,
372
+ step: frame.step,
373
+ startedAfterSeq,
374
+ entries: [],
375
+ });
376
+ return;
377
+ }
378
+ const stream = this.#assistantStreams.get(sessionId);
379
+ if (!stream || stream.attemptId !== frame.attemptId) return;
380
+ if (frame.type === 'end') {
381
+ this.#assistantStreams.delete(sessionId);
382
+ return;
383
+ }
384
+ if (frame.type !== 'chunk' || frame.index !== stream.entries.length) return;
385
+ stream.entries.push({
386
+ type: 'transient',
387
+ event: {
388
+ type: 'assistant/chunk',
389
+ // Match DSH's Web client ordering: live chunks sit in the numeric gap
390
+ // before the durable assistant/message that replaces them.
391
+ seq: stream.startedAfterSeq + 1 - 1 / (frame.index + 2),
392
+ time: frame.time,
393
+ data: { turn: stream.turn, step: stream.step, chunk: frame.chunk },
394
+ },
395
+ });
396
+ }
397
+
398
+ #withAssistantStream(sessionId, entries) {
399
+ const transient = this.#assistantStreams.get(sessionId)?.entries ?? [];
400
+ return transient.length === 0 ? entries : [...entries, ...transient];
346
401
  }
347
402
 
348
403
  #rememberCursor(sessionId, cursor) {
@@ -556,6 +611,7 @@ class ModernHarnessApi {
556
611
  if (this.#disposed) return;
557
612
  this.#disposed = true;
558
613
  for (const subscription of [...this.#mux]) subscription.close();
614
+ this.#assistantStreams.clear();
559
615
  for (const pending of [...this.#pendingApprovals.values()]) pending.settle('cancelled');
560
616
  for (const pending of [...this.#pendingQuestions.values()]) pending.settle(
561
617
  'cancelled',
@@ -0,0 +1,217 @@
1
+ import {
2
+ AssistantTextAccumulator,
3
+ consumeDshImInputOrigin,
4
+ textFromHarnessContent,
5
+ } from '../../src/channels/shared/harness-client.mjs';
6
+
7
+ const DSH_USER_PREFIX = '[来自 DSH]\n';
8
+ const DSH_ASSISTANT_PREFIX = '[DSH 助手]\n';
9
+
10
+ function completedTurn(reason) {
11
+ return (typeof reason === 'string' ? reason : reason?.kind) === 'completed';
12
+ }
13
+
14
+ function recipientKey(target) {
15
+ return JSON.stringify([target.channel, target.botId, target.targetId]);
16
+ }
17
+
18
+ function sessionIdOf(session) {
19
+ const value = session?.id ?? session?.sessionId;
20
+ return typeof value === 'string' && value ? value : null;
21
+ }
22
+
23
+ function userInputOrigin(scope, event) {
24
+ const source = event?.data?.source;
25
+ if (source?.kind !== 'user' || typeof source.rpcId !== 'string' || !source.rpcId) {
26
+ return 'other';
27
+ }
28
+ return consumeDshImInputOrigin(scope, source.rpcId) ? 'im' : 'dsh';
29
+ }
30
+
31
+ function validRecipient(target) {
32
+ return target && typeof target === 'object'
33
+ && typeof target.channel === 'string' && target.channel
34
+ && typeof target.botId === 'string' && target.botId
35
+ && typeof target.targetId === 'string' && target.targetId;
36
+ }
37
+
38
+ export function createSessionSyncCoordinator({ deliveryService, logger = console }) {
39
+ if (typeof deliveryService?.listSessionSyncTargets !== 'function'
40
+ || typeof deliveryService?.sendSessionSyncText !== 'function') {
41
+ throw new TypeError('Session sync requires a complete delivery service');
42
+ }
43
+
44
+ const turns = new Map();
45
+ const tails = new Map();
46
+ let closed = false;
47
+
48
+ const logFailure = (phase, target, error) => {
49
+ logger.warn?.(
50
+ `[dsh-im] ignored Session sync ${phase} failure`
51
+ + (target ? ` (${target.channel}/${target.botId}/${target.targetId})` : '')
52
+ + ` [${error?.code ?? error?.name ?? 'unknown-error'}]`,
53
+ );
54
+ };
55
+
56
+ const deliver = async (sessionId, targets, text, phase) => {
57
+ const unique = new Map();
58
+ for (const target of targets) {
59
+ if (validRecipient(target)) unique.set(recipientKey(target), target);
60
+ }
61
+ const entries = [...unique.entries()];
62
+ const results = await Promise.allSettled(entries.map(([, target]) => (
63
+ deliveryService.sendSessionSyncText(
64
+ target.botId,
65
+ target.targetId,
66
+ sessionId,
67
+ text,
68
+ )
69
+ )));
70
+ const successful = new Map();
71
+ results.forEach((result, index) => {
72
+ const [key, target] = entries[index];
73
+ if (result.status === 'fulfilled') successful.set(key, target);
74
+ else logFailure(phase, target, result.reason);
75
+ });
76
+ return successful;
77
+ };
78
+
79
+ const processEvent = async (sessionId, event, origin) => {
80
+ if (closed || !event || typeof event !== 'object') return;
81
+
82
+ if (event.type === 'turn/start') {
83
+ const turn = event.data?.turn;
84
+ if (!Number.isSafeInteger(turn)) return;
85
+ turns.set(sessionId, {
86
+ turn,
87
+ step: null,
88
+ origin: 'unknown',
89
+ recipients: null,
90
+ assistant: new AssistantTextAccumulator(),
91
+ });
92
+ return;
93
+ }
94
+
95
+ const state = turns.get(sessionId);
96
+ if (!state) return;
97
+
98
+ if (event.type === 'step/start') {
99
+ if (event.data?.turn !== undefined && event.data.turn !== state.turn) return;
100
+ state.step = Number.isSafeInteger(event.data?.step) ? event.data.step : null;
101
+ return;
102
+ }
103
+
104
+ if (event.type === 'step/end') {
105
+ if (event.data?.turn !== undefined && event.data.turn !== state.turn) return;
106
+ if (event.data?.step === undefined || event.data.step === state.step) state.step = null;
107
+ return;
108
+ }
109
+
110
+ if (event.type === 'user/message') {
111
+ if (event.surfaceOp !== 'append') return;
112
+ if (state.origin === 'unknown') state.origin = origin;
113
+ if (state.origin !== 'dsh' || origin !== 'dsh') return;
114
+ const text = textFromHarnessContent(event.data?.content);
115
+ if (!text) return;
116
+
117
+ let targets;
118
+ try {
119
+ targets = await deliveryService.listSessionSyncTargets(sessionId);
120
+ } catch (error) {
121
+ logFailure('lookup', null, error);
122
+ targets = [];
123
+ }
124
+ const successful = await deliver(
125
+ sessionId,
126
+ targets,
127
+ `${DSH_USER_PREFIX}${text}`,
128
+ 'user delivery',
129
+ );
130
+ if (state.recipients === null) {
131
+ state.recipients = successful;
132
+ } else {
133
+ for (const key of [...state.recipients.keys()]) {
134
+ if (!successful.has(key)) state.recipients.delete(key);
135
+ }
136
+ }
137
+ return;
138
+ }
139
+
140
+ if (event.type === 'assistant/message') {
141
+ if (state.origin !== 'dsh' || event.surfaceOp !== 'append'
142
+ || event.data?.interrupted === true
143
+ || (event.data?.turn !== undefined && event.data.turn !== state.turn)) return;
144
+ const text = textFromHarnessContent(event.data?.message?.content);
145
+ const step = Number.isSafeInteger(event.data?.step) ? event.data.step : state.step;
146
+ state.assistant.setCanonical(step, text);
147
+ return;
148
+ }
149
+
150
+ if (event.type !== 'turn/end' || event.data?.turn !== state.turn) return;
151
+ turns.delete(sessionId);
152
+ if (state.origin !== 'dsh' || !completedTurn(event.data?.reason)
153
+ || !state.recipients?.size || !state.assistant.text) return;
154
+ await deliver(
155
+ sessionId,
156
+ state.recipients.values(),
157
+ `${DSH_ASSISTANT_PREFIX}${state.assistant.text}`,
158
+ 'assistant delivery',
159
+ );
160
+ };
161
+
162
+ const enqueue = (sessionId, event, origin = 'other') => {
163
+ if (closed || typeof sessionId !== 'string' || !sessionId) return Promise.resolve();
164
+ const previous = tails.get(sessionId) ?? Promise.resolve();
165
+ const task = previous.then(
166
+ () => processEvent(sessionId, event, origin),
167
+ () => processEvent(sessionId, event, origin),
168
+ );
169
+ const tail = task.catch((error) => logFailure('event handling', null, error)).finally(() => {
170
+ if (tails.get(sessionId) === tail) tails.delete(sessionId);
171
+ });
172
+ tails.set(sessionId, tail);
173
+ return tail;
174
+ };
175
+
176
+ return Object.freeze({
177
+ enqueue,
178
+ async whenIdle() {
179
+ while (tails.size > 0) await Promise.allSettled([...tails.values()]);
180
+ },
181
+ close() {
182
+ closed = true;
183
+ turns.clear();
184
+ },
185
+ });
186
+ }
187
+
188
+ export function installSessionSyncCoordinator(ctx, deliveryService, {
189
+ logger = console,
190
+ inputScope = ctx?.root ?? ctx,
191
+ } = {}) {
192
+ if (typeof ctx?.on !== 'function') {
193
+ throw new TypeError('Session sync requires Host session events');
194
+ }
195
+ const coordinator = createSessionSyncCoordinator({ deliveryService, logger });
196
+ const disposeEvent = ctx.on('session/event', (session, event) => {
197
+ const sessionId = sessionIdOf(session);
198
+ if (!sessionId) return;
199
+ // Origin lookup must be synchronous: the originating ask may clean its id
200
+ // immediately after this event callback returns.
201
+ const origin = event?.type === 'user/message'
202
+ ? userInputOrigin(inputScope, event)
203
+ : 'other';
204
+ void coordinator.enqueue(sessionId, event, origin);
205
+ }, { global: true });
206
+ let disposed = false;
207
+ const close = () => {
208
+ if (disposed) return;
209
+ disposed = true;
210
+ disposeEvent?.();
211
+ coordinator.close();
212
+ };
213
+ if (typeof ctx.effect === 'function') {
214
+ ctx.effect(() => close, 'dsh-im: Session sync coordinator');
215
+ }
216
+ return Object.freeze({ ...coordinator, close });
217
+ }
@@ -141,15 +141,21 @@ if ((client.match(/\.slots\.inject\(\s*["']settings\.section["']/gu) ?? []).leng
141
141
  if (client.includes('settings.plugins.tab') || clientSources.includes('settings.plugins.tab')) {
142
142
  throw new Error('client source or bundle still contains the legacy Plugins-tab settings entry');
143
143
  }
144
- // Connections still have no channel-enable toggle. Only the shared context
145
- // editor owns checkable inputs. Its reusable scope component contains one
146
- // switch template and one mapped field-input template; it renders both twice.
144
+ // Connections still have no channel-enable toggle. Checkable inputs are owned
145
+ // only by the shared context editor and the saved-target Session sync row.
146
+ // The context editor contains one switch template and one mapped field-input
147
+ // template; the delivery target adds one ordinary checkbox template.
147
148
  const contextEditorSource = await readFile(resolve(root, 'plugin-src/client/context-enhancement.js'), 'utf8');
148
- const otherClientSources = clientSources.replace(contextEditorSource, '');
149
+ const deliverySettingsSource = await readFile(resolve(root, 'plugin-src/client/delivery-settings.js'), 'utf8');
150
+ const otherClientSources = clientSources
151
+ .replace(contextEditorSource, '')
152
+ .replace(deliverySettingsSource, '');
149
153
  if (/role:\s*["']switch|type:\s*["']checkbox/.test(otherClientSources)
154
+ || (deliverySettingsSource.match(/type:\s*["']checkbox["']/g) ?? []).length !== 1
155
+ || /role:\s*["']switch["']/u.test(deliverySettingsSource)
150
156
  || (client.match(/role:\s*["']switch["']/g) ?? []).length !== 1
151
- || (client.match(/type:\s*["']checkbox["']/g) ?? []).length !== 2) {
152
- throw new Error('checkable inputs must be limited to the context-enhancement editor');
157
+ || (client.match(/type:\s*["']checkbox["']/g) ?? []).length !== 3) {
158
+ throw new Error('checkable inputs must be limited to context enhancement and Session sync');
153
159
  }
154
160
  for (const marker of ['bot.context-enhancement.set', '<dsh_im_source>', '<dsh_im_source_guidance>']) {
155
161
  if (!host.includes(marker) || !client.includes(marker)) {