@zhin.js/service-activity-feedback 3.0.14 → 3.0.16

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/src/executor.ts CHANGED
@@ -1,5 +1,4 @@
1
- import type { Adapter } from 'zhin.js';
2
- import { enableActivityFeedbackForBot, isGenericActivityFeedbackManager, type ActivityFeedbackManager, type ActivityFeedbackPhase, type EndpointWithActivityFeedback, type PlatformActivityFeedbackManager, type ResolvedActivityFeedbackPhaseConfig, type ActivityFeedbackEventContext } from '@zhin.js/agent';
1
+ import { enableActivityFeedbackForBot, isGenericActivityFeedbackManager, type ActivityFeedbackManager, type ActivityFeedbackPhase, type ActivityFeedbackSendPort, type EndpointWithActivityFeedback, type PlatformActivityFeedbackManager, type ResolvedActivityFeedbackPhaseConfig, type ActivityFeedbackEventContext } from '@zhin.js/agent';
3
2
  import type { OutboundHost } from 'zhin.js';
4
3
 
5
4
  /** IM 侧 endpoint 访问 seam(便于测试注入 fake) */
@@ -7,7 +6,7 @@ export interface ActivityFeedbackEndpointAccess {
7
6
  resolve(
8
7
  platform: string,
9
8
  endpointKey: string,
10
- ): { endpoint: EndpointWithActivityFeedback; adapter: Adapter } | undefined;
9
+ ): { endpoint: EndpointWithActivityFeedback; outbound: ActivityFeedbackSendPort } | undefined;
11
10
  }
12
11
 
13
12
  /** Slice-2: no Adapter inject — executor start/stop no-op when resolve returns undefined. */
@@ -42,7 +41,7 @@ function stringifySendContent(content: unknown): string {
42
41
  * Plugin Runtime: resolve endpoints via OutboundHost → ImRuntime.sendEndpointMessage.
43
42
  * Typing/reaction text goes through the unified outbound chain (no legacy Adapter.inject).
44
43
  *
45
- * 按 platform:endpointKey 缓存 { endpoint, adapter }:activity manager 挂在
44
+ * 按 platform:endpointKey 缓存 { endpoint, outbound }:activity manager 挂在
46
45
  * endpoint.$activityFeedback 上,start/stop 必须解析到同一个对象,否则
47
46
  * stop 时拿不到 manager,typing 指示器永远无法停止。
48
47
  */
@@ -50,19 +49,27 @@ export function createOutboundEndpointAccess(
50
49
  outbound: OutboundHost,
51
50
  logger?: { debug: (msg: string, ...args: unknown[]) => void },
52
51
  ): ActivityFeedbackEndpointAccess {
53
- const cache = new Map<string, { endpoint: EndpointWithActivityFeedback; adapter: Adapter }>();
52
+ const cache = new Map<string, {
53
+ endpoint: EndpointWithActivityFeedback;
54
+ outbound: ActivityFeedbackSendPort;
55
+ }>();
54
56
  return {
55
57
  resolve(platform, endpointKey) {
56
- const key = `${platform}:${endpointKey}`;
58
+ const key = JSON.stringify([platform, endpointKey]);
57
59
  const cached = cache.get(key);
58
60
  if (cached) return cached;
61
+ const declared = outbound.capabilities?.({ adapter: platform, endpointKey })?.operations;
62
+ const supports = (operation: 'recall' | 'edit' | 'reaction' | 'typing') =>
63
+ declared?.includes(operation) === true;
59
64
  const recall = outbound.recall;
65
+ const edit = outbound.edit;
60
66
  const addReaction = outbound.addReaction;
61
67
  const removeReaction = outbound.removeReaction;
68
+ const typing = outbound.typing;
62
69
  const endpoint = {
63
70
  $id: endpointKey,
64
71
  control: {
65
- ...(recall ? {
72
+ ...(recall && supports('recall') ? {
66
73
  recall: async (message: Parameters<typeof recall>[0]['message']) => {
67
74
  try {
68
75
  await recall({ adapter: platform, endpointKey, message });
@@ -74,7 +81,23 @@ export function createOutboundEndpointAccess(
74
81
  }
75
82
  },
76
83
  } : {}),
77
- ...(addReaction ? {
84
+ ...(edit && supports('edit') ? {
85
+ edit: async (
86
+ message: Parameters<typeof edit>[0]['message'],
87
+ content: unknown,
88
+ ) => {
89
+ try {
90
+ return await edit({ adapter: platform, endpointKey, message, content });
91
+ } catch (error) {
92
+ logger?.debug(
93
+ `[ActivityFeedback] outbound edit failed (${key}):`,
94
+ error instanceof Error ? error.message : String(error),
95
+ );
96
+ return null;
97
+ }
98
+ },
99
+ } : {}),
100
+ ...(addReaction && supports('reaction') ? {
78
101
  addReaction: async (
79
102
  message: Parameters<typeof addReaction>[0]['message'],
80
103
  emoji: string,
@@ -98,44 +121,53 @@ export function createOutboundEndpointAccess(
98
121
  }
99
122
  },
100
123
  } : {}),
101
- ...(removeReaction ? {
124
+ ...(removeReaction && supports('reaction') ? {
102
125
  removeReaction: async (
103
126
  message: Parameters<typeof removeReaction>[0]['message'],
104
127
  reactionId: string,
105
128
  ) => {
106
- void Promise.resolve(
107
- removeReaction({ adapter: platform, endpointKey, message, reactionId }),
108
- ).catch((error) => {
129
+ try {
130
+ await removeReaction({ adapter: platform, endpointKey, message, reactionId });
131
+ } catch (error) {
109
132
  logger?.debug(
110
133
  `[ActivityFeedback] outbound removeReaction failed (${key}):`,
111
134
  error instanceof Error ? error.message : String(error),
112
135
  );
113
- });
136
+ }
137
+ },
138
+ } : {}),
139
+ ...(typing && supports('typing') ? {
140
+ typing: async (
141
+ conversation: Parameters<typeof typing>[0]['conversation'],
142
+ active?: boolean,
143
+ ) => {
144
+ try {
145
+ await typing({ adapter: platform, endpointKey, conversation, active });
146
+ } catch (error) {
147
+ logger?.debug(
148
+ `[ActivityFeedback] outbound typing failed (${key}):`,
149
+ error instanceof Error ? error.message : String(error),
150
+ );
151
+ }
114
152
  },
115
153
  } : {}),
116
154
  },
117
155
  } as EndpointWithActivityFeedback;
118
- const adapter = {
119
- sendMessage: async (options: {
120
- type?: string;
121
- id?: string;
122
- content?: unknown;
123
- }) => {
124
- const text = stringifySendContent(options.content);
125
- if (!text || !options.id) return null;
156
+ const sendPort: ActivityFeedbackSendPort = {
157
+ send: async ({ conversation, content }) => {
158
+ const text = stringifySendContent(content);
159
+ if (!text || !conversation.id) return null;
126
160
  try {
127
161
  const messageId = await outbound.send({
128
162
  adapter: platform,
129
163
  endpointKey,
130
164
  conversation: {
131
- kind: (options.type as 'private' | 'group' | 'channel' | undefined) || 'private',
132
- id: options.id,
165
+ kind: conversation.kind,
166
+ id: conversation.id,
133
167
  },
134
168
  content: text,
135
169
  });
136
- // Prefer real id; fall back to a sentinel so MessageTypingIndicator
137
- // keeps the phase active until stop (recall is a no-op here).
138
- return messageId || `outbound:${Date.now()}`;
170
+ return messageId || null;
139
171
  } catch (error) {
140
172
  logger?.debug(
141
173
  `[ActivityFeedback] outbound send failed (${key}):`,
@@ -144,11 +176,8 @@ export function createOutboundEndpointAccess(
144
176
  return null;
145
177
  }
146
178
  },
147
- endpoints: {
148
- get: (id: string) => (id === endpointKey ? endpoint : undefined),
149
- },
150
179
  };
151
- const resolved = { endpoint, adapter: adapter as unknown as Adapter };
180
+ const resolved = { endpoint, outbound: sendPort };
152
181
  cache.set(key, resolved);
153
182
  return resolved;
154
183
  },
@@ -208,7 +237,7 @@ class GenericPhaseDriver implements PhaseDriver {
208
237
  constructor(
209
238
  private readonly endpoint: EndpointWithActivityFeedback,
210
239
  private readonly platform: string,
211
- private readonly adapter: Adapter,
240
+ private readonly outbound: ActivityFeedbackSendPort,
212
241
  ) {}
213
242
 
214
243
  private async ensureManager(): Promise<ActivityFeedbackManager> {
@@ -218,7 +247,7 @@ class GenericPhaseDriver implements PhaseDriver {
218
247
  this.manager = existing;
219
248
  return existing;
220
249
  }
221
- this.manager = enableActivityFeedbackForBot(this.endpoint, this.platform, this.adapter);
250
+ this.manager = enableActivityFeedbackForBot(this.endpoint, this.platform, this.outbound);
222
251
  return this.manager;
223
252
  }
224
253
 
@@ -251,13 +280,13 @@ class GenericPhaseDriver implements PhaseDriver {
251
280
  function createPhaseDriver(
252
281
  endpoint: EndpointWithActivityFeedback,
253
282
  platform: string,
254
- adapter: Adapter,
283
+ outbound: ActivityFeedbackSendPort,
255
284
  ): PhaseDriver {
256
285
  const manager = endpoint.$activityFeedback;
257
286
  if (manager && !isGenericActivityFeedbackManager(manager)) {
258
287
  return new PlatformPhaseDriver(manager);
259
288
  }
260
- return new GenericPhaseDriver(endpoint, platform, adapter);
289
+ return new GenericPhaseDriver(endpoint, platform, outbound);
261
290
  }
262
291
 
263
292
  export class ActivityFeedbackExecutor {
@@ -270,21 +299,35 @@ export class ActivityFeedbackExecutor {
270
299
  ): Promise<void> {
271
300
  const resolved = this.access.resolve(ctx.platform, ctx.endpointKey);
272
301
  if (!resolved) return;
273
- const driver = createPhaseDriver(resolved.endpoint, ctx.platform, resolved.adapter);
302
+ const driver = createPhaseDriver(resolved.endpoint, ctx.platform, resolved.outbound);
274
303
  await driver.start(ctx, phase, phaseConfig);
275
304
  }
276
305
 
277
306
  async stop(ctx: ActivityFeedbackEventContext, phase: ActivityFeedbackPhase): Promise<void> {
278
307
  const resolved = this.access.resolve(ctx.platform, ctx.endpointKey);
279
308
  if (!resolved?.endpoint.$activityFeedback) return;
280
- const driver = createPhaseDriver(resolved.endpoint, ctx.platform, resolved.adapter);
309
+ const driver = createPhaseDriver(resolved.endpoint, ctx.platform, resolved.outbound);
281
310
  await driver.stop(ctx, phase);
282
311
  }
283
312
 
284
- async updateThinkingText(ctx: ActivityFeedbackEventContext, text: string): Promise<void> {
313
+ async updateText(
314
+ ctx: ActivityFeedbackEventContext,
315
+ phase: ActivityFeedbackPhase,
316
+ text: string,
317
+ ): Promise<void> {
285
318
  const resolved = this.access.resolve(ctx.platform, ctx.endpointKey);
286
319
  if (!resolved) return;
287
- const driver = createPhaseDriver(resolved.endpoint, ctx.platform, resolved.adapter);
288
- await driver.updateThinkingText(ctx, text);
320
+ const driver = createPhaseDriver(resolved.endpoint, ctx.platform, resolved.outbound);
321
+ const manager = resolved.endpoint.$activityFeedback;
322
+ if (!manager || !isGenericActivityFeedbackManager(manager)) {
323
+ if (phase === 'thinking') await driver.updateThinkingText(ctx, text);
324
+ return;
325
+ }
326
+ const indicator = manager.getActiveIndicator(phase, ctx.options);
327
+ if (indicator?.update) await indicator.update(text);
328
+ }
329
+
330
+ async updateThinkingText(ctx: ActivityFeedbackEventContext, text: string): Promise<void> {
331
+ await this.updateText(ctx, 'thinking', text);
289
332
  }
290
333
  }
@@ -12,6 +12,17 @@ import { ActivityFeedbackPolicy } from './policy.js';
12
12
  type Logger = { debug: (msg: string, ...args: unknown[]) => void; error: (msg: string, ...args: unknown[]) => void };
13
13
 
14
14
  export class ActivityFeedbackOrchestrator {
15
+ private readonly active = new Map<string, {
16
+ ctx: NonNullable<ReturnType<typeof toActivityFeedbackEventContext>>;
17
+ phase: ActivityFeedbackPhase;
18
+ }>();
19
+ private readonly transient = new Map<string, {
20
+ timer: ReturnType<typeof setTimeout>;
21
+ ctx: NonNullable<ReturnType<typeof toActivityFeedbackEventContext>>;
22
+ phase: ActivityFeedbackPhase;
23
+ }>();
24
+ private readonly pendingCleanup = new Set<Promise<void>>();
25
+
15
26
  constructor(
16
27
  private readonly policy: ActivityFeedbackPolicy,
17
28
  private readonly executor: ActivityFeedbackExecutor,
@@ -49,6 +60,7 @@ export class ActivityFeedbackOrchestrator {
49
60
  const config = applySubagentActivityPrefixToConfig(resolution.config, payload);
50
61
  this.log.debug(`[ActivityFeedback] start ${phase} (${reason}) session=${ctx.sessionId}`);
51
62
  await this.executor.start(ctx, phase, config);
63
+ this.active.set(this.phaseKey(ctx, phase), { ctx, phase });
52
64
  } catch (error) {
53
65
  this.log.error(`[ActivityFeedback] start ${phase} failed (${reason}):`, error);
54
66
  }
@@ -57,6 +69,9 @@ export class ActivityFeedbackOrchestrator {
57
69
  async stopPhase(payload: AIEventPayload, phase: ActivityFeedbackPhase, reason: string): Promise<void> {
58
70
  const ctx = toActivityFeedbackEventContext(payload);
59
71
  if (!ctx) return;
72
+ const key = this.phaseKey(ctx, phase);
73
+ this.clearTransient(key);
74
+ this.active.delete(key);
60
75
 
61
76
  try {
62
77
  this.log.debug(`[ActivityFeedback] stop ${phase} (${reason}) session=${ctx.sessionId}`);
@@ -67,13 +82,76 @@ export class ActivityFeedbackOrchestrator {
67
82
  }
68
83
 
69
84
  async updateThinkingText(payload: AIEventPayload, text: string): Promise<void> {
85
+ await this.updatePhaseText(payload, 'thinking', text);
86
+ }
87
+
88
+ async updatePhaseText(
89
+ payload: AIEventPayload,
90
+ phase: ActivityFeedbackPhase,
91
+ text: string,
92
+ ): Promise<void> {
70
93
  const ctx = toActivityFeedbackEventContext(payload);
71
94
  if (!ctx || !text) return;
72
95
 
73
96
  try {
74
- await this.executor.updateThinkingText(ctx, withSubagentActivityPrefix(text, payload));
97
+ await this.executor.updateText(ctx, phase, withSubagentActivityPrefix(text, payload));
75
98
  } catch (error) {
76
- this.log.error('[ActivityFeedback] thinking update failed:', error);
99
+ this.log.error(`[ActivityFeedback] ${phase} update failed:`, error);
77
100
  }
78
101
  }
102
+
103
+ async showTransientPhase(
104
+ payload: AIEventPayload,
105
+ phase: ActivityFeedbackPhase,
106
+ reason: string,
107
+ ): Promise<void> {
108
+ await this.startPhase(payload, phase, reason);
109
+ const ctx = toActivityFeedbackEventContext(payload);
110
+ if (!ctx) return;
111
+ const resolution = this.policy.resolvePhase(ctx.platform, ctx.endpointKey, phase, ctx.sceneType);
112
+ if (resolution.kind !== 'active') return;
113
+ const delay = resolution.config.removeDelay ?? 3_000;
114
+ const key = this.phaseKey(ctx, phase);
115
+ this.clearTransient(key);
116
+ const timer = setTimeout(() => {
117
+ this.transient.delete(key);
118
+ this.active.delete(key);
119
+ const cleanup = this.executor.stop(ctx, phase)
120
+ .catch((error) => {
121
+ this.log.error(`[ActivityFeedback] transient ${phase} cleanup failed:`, error);
122
+ })
123
+ .finally(() => {
124
+ this.pendingCleanup.delete(cleanup);
125
+ });
126
+ this.pendingCleanup.add(cleanup);
127
+ }, Math.max(0, delay));
128
+ timer.unref?.();
129
+ this.transient.set(key, { timer, ctx, phase });
130
+ }
131
+
132
+ async dispose(): Promise<void> {
133
+ const pending = [...this.active.values()];
134
+ const timers = [...this.transient.values()];
135
+ this.active.clear();
136
+ this.transient.clear();
137
+ for (const item of timers) clearTimeout(item.timer);
138
+ await Promise.allSettled([
139
+ ...pending.map((item) => this.executor.stop(item.ctx, item.phase)),
140
+ ...this.pendingCleanup,
141
+ ]);
142
+ }
143
+
144
+ private phaseKey(
145
+ ctx: NonNullable<ReturnType<typeof toActivityFeedbackEventContext>>,
146
+ phase: ActivityFeedbackPhase,
147
+ ): string {
148
+ return JSON.stringify([ctx.platform, ctx.endpointKey, ctx.sessionId, phase, ctx.messageId]);
149
+ }
150
+
151
+ private clearTransient(key: string): void {
152
+ const current = this.transient.get(key);
153
+ if (!current) return;
154
+ clearTimeout(current.timer);
155
+ this.transient.delete(key);
156
+ }
79
157
  }
package/src/policy.ts CHANGED
@@ -35,6 +35,7 @@ export class ActivityFeedbackPolicy {
35
35
  this.service.defaults,
36
36
  this.service.platforms?.[platform],
37
37
  this.service.endpoints?.[`${platform}:${endpointKey}`],
38
+ schedulePhaseLayer(this.service, phase),
38
39
  );
39
40
 
40
41
  if (policy?.enabled === false) {
@@ -49,3 +50,18 @@ export class ActivityFeedbackPolicy {
49
50
  return { kind: 'active', config };
50
51
  }
51
52
  }
53
+
54
+ function schedulePhaseLayer(
55
+ service: ActivityFeedbackServiceConfig,
56
+ phase: ActivityFeedbackPhase,
57
+ ): import('@zhin.js/agent').ActivityFeedbackConfig | undefined {
58
+ const scheduleKey = phase === 'schedule_start'
59
+ ? 'start'
60
+ : phase === 'schedule_finish'
61
+ ? 'finish'
62
+ : phase === 'schedule_error'
63
+ ? 'error'
64
+ : undefined;
65
+ const scenes = scheduleKey ? service.schedule?.phases?.[scheduleKey] : undefined;
66
+ return scenes ? { phases: { [phase]: scenes } } : undefined;
67
+ }