@zhin.js/service-activity-feedback 1.0.1 → 1.1.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/plugin.js ADDED
@@ -0,0 +1,43 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { createGenerationAdmissionGate, createToken, definePlugin, outboundHostToken, } from 'zhin.js';
3
+ import { getLogger } from '@zhin.js/logger';
4
+ import { loadActivityFeedbackServiceConfig, } from "./lib/config.js";
5
+ import { bindActivityFeedbackToAIEventBus, createActivityFeedbackOrchestratorForRuntime, } from "./lib/ai-event-binder.js";
6
+ import { createOutboundEndpointAccess, } from "./lib/executor.js";
7
+ const logger = getLogger('activity-feedback');
8
+ const activityFeedbackAdmissionToken = createToken('zhin.activity-feedback.generation-admission');
9
+ /**
10
+ * Activity Feedback service — Plugin Runtime entry.
11
+ *
12
+ * Subscribes AI lifecycle events via `activityFeedbackAiBus`.
13
+ * When Root provides `outboundHostToken`, typing/status text uses
14
+ * ImRuntime.sendEndpointMessage; otherwise phases no-op.
15
+ */
16
+ export default definePlugin({
17
+ name: 'activity-feedback',
18
+ metadata: {
19
+ displayName: 'Activity Feedback',
20
+ },
21
+ setup(context) {
22
+ const serviceConfig = loadActivityFeedbackServiceConfig(context.config.get());
23
+ if (serviceConfig.enabled === false) {
24
+ return;
25
+ }
26
+ const outbound = context.resources.has(outboundHostToken)
27
+ ? context.resources.use(outboundHostToken)
28
+ : undefined;
29
+ if (!outbound || typeof outbound.runWithView !== 'function') {
30
+ logger.debug('[ActivityFeedback] disabled: OutboundHost with generation-bound runWithView is required');
31
+ return;
32
+ }
33
+ const access = createOutboundEndpointAccess(outbound, logger);
34
+ const orchestrator = createActivityFeedbackOrchestratorForRuntime(serviceConfig, logger, access);
35
+ const admission = createGenerationAdmissionGate();
36
+ context.resources.provide(activityFeedbackAdmissionToken, admission);
37
+ const dispose = bindActivityFeedbackToAIEventBus(orchestrator, admission, outbound.runWithView.bind(outbound));
38
+ context.lifecycle.add(async () => {
39
+ logger.debug('[ActivityFeedback] Disposing Runtime AI event binder');
40
+ await dispose();
41
+ });
42
+ },
43
+ });
package/schema.json ADDED
@@ -0,0 +1,128 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "enabled": {
7
+ "type": "boolean",
8
+ "default": true,
9
+ "description": "Enable activity feedback for this plugin instance.",
10
+ "x-descriptionZh": "是否启用当前活动反馈插件实例。"
11
+ },
12
+ "defaults": {
13
+ "$ref": "#/$defs/activityFeedback",
14
+ "description": "Default policy for all platforms and Endpoints.",
15
+ "x-descriptionZh": "所有平台与 Endpoint 的默认策略。"
16
+ },
17
+ "platforms": {
18
+ "type": "object",
19
+ "x-keyPlaceholder": "<platform>",
20
+ "additionalProperties": { "$ref": "#/$defs/activityFeedback" },
21
+ "description": "Platform overrides keyed by platform name; values use the same shape as defaults.",
22
+ "x-descriptionZh": "按平台名覆盖;值的结构与 defaults 相同。"
23
+ },
24
+ "endpoints": {
25
+ "type": "object",
26
+ "x-keyPlaceholder": "<platform:endpointKey>",
27
+ "additionalProperties": { "$ref": "#/$defs/activityFeedback" },
28
+ "description": "Endpoint overrides keyed by platform:endpointKey; values use the same shape as defaults.",
29
+ "x-descriptionZh": "按 platform:endpointKey 覆盖;值的结构与 defaults 相同。"
30
+ },
31
+ "schedule": {
32
+ "$ref": "#/$defs/scheduleFeedback",
33
+ "description": "Schedule-only start, finish, and error feedback.",
34
+ "x-descriptionZh": "仅用于 Schedule 的开始、完成与失败反馈。"
35
+ }
36
+ },
37
+ "$defs": {
38
+ "activityFeedback": {
39
+ "type": "object",
40
+ "additionalProperties": false,
41
+ "properties": {
42
+ "enabled": {
43
+ "type": "boolean",
44
+ "default": true,
45
+ "description": "Enable this policy layer.",
46
+ "x-descriptionZh": "是否启用当前策略层。"
47
+ },
48
+ "phases": {
49
+ "type": "object",
50
+ "additionalProperties": false,
51
+ "description": "Feedback policy keyed by lifecycle phase.",
52
+ "x-descriptionZh": "按生命周期 phase 配置反馈策略。",
53
+ "properties": {
54
+ "queued": { "$ref": "#/$defs/scenePhases" },
55
+ "active": { "$ref": "#/$defs/scenePhases" },
56
+ "thinking": { "$ref": "#/$defs/scenePhases" },
57
+ "schedule_start": { "$ref": "#/$defs/scenePhases" },
58
+ "schedule_finish": { "$ref": "#/$defs/scenePhases" },
59
+ "schedule_error": { "$ref": "#/$defs/scenePhases" }
60
+ }
61
+ }
62
+ }
63
+ },
64
+ "scenePhases": {
65
+ "type": "object",
66
+ "additionalProperties": false,
67
+ "properties": {
68
+ "private": { "$ref": "#/$defs/phaseConfig" },
69
+ "group": { "$ref": "#/$defs/phaseConfig" },
70
+ "channel": { "$ref": "#/$defs/phaseConfig" }
71
+ }
72
+ },
73
+ "phaseConfig": {
74
+ "type": "object",
75
+ "additionalProperties": false,
76
+ "properties": {
77
+ "type": {
78
+ "type": "string",
79
+ "enum": ["reaction", "message", "typing", "none"],
80
+ "description": "How the phase is presented.",
81
+ "x-descriptionZh": "该 phase 的呈现方式。"
82
+ },
83
+ "emoji": {
84
+ "type": "string",
85
+ "description": "Reaction value for type=reaction.",
86
+ "x-descriptionZh": "type=reaction 时使用的回应值。"
87
+ },
88
+ "message": {
89
+ "type": "string",
90
+ "description": "Status text for type=message.",
91
+ "x-descriptionZh": "type=message 时使用的状态文本。"
92
+ },
93
+ "autoRemove": {
94
+ "type": "boolean",
95
+ "default": true,
96
+ "description": "Remove the feedback after the phase stops.",
97
+ "x-descriptionZh": "phase 停止后是否自动移除反馈。"
98
+ },
99
+ "removeDelay": {
100
+ "type": "number",
101
+ "description": "Delay before removal in milliseconds; negative values are normalized to zero at runtime.",
102
+ "x-descriptionZh": "自动移除前等待的毫秒数;负值会在运行时归一化为 0。"
103
+ },
104
+ "platformConfig": {
105
+ "type": "object",
106
+ "additionalProperties": true,
107
+ "description": "Platform-specific options.",
108
+ "x-descriptionZh": "平台特定选项。"
109
+ }
110
+ }
111
+ },
112
+ "scheduleFeedback": {
113
+ "type": "object",
114
+ "additionalProperties": false,
115
+ "properties": {
116
+ "phases": {
117
+ "type": "object",
118
+ "additionalProperties": false,
119
+ "properties": {
120
+ "start": { "$ref": "#/$defs/scenePhases" },
121
+ "finish": { "$ref": "#/$defs/scenePhases" },
122
+ "error": { "$ref": "#/$defs/scenePhases" }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
@@ -1,19 +1,25 @@
1
- import type { Plugin } from 'zhin.js';
2
- import { subscribeAIEvents, isActivityFeedbackEnabled } from '@zhin.js/agent';
1
+ import {
2
+ subscribeAIEventsOnTarget,
3
+ activityFeedbackAiBus,
4
+ isActivityFeedbackEnabled,
5
+ type AIEventHandlers,
6
+ type AIEventPayload,
7
+ type AIEventTarget,
8
+ } from '@zhin.js/agent';
3
9
  import { loadActivityFeedbackServiceConfig, type ActivityFeedbackServiceConfig } from './config.js';
10
+ import type { GenerationAdmissionGate } from 'zhin.js';
4
11
  import {
5
12
  ActivityFeedbackExecutor,
6
- createRootEndpointAccess,
13
+ createNoopEndpointAccess,
7
14
  type ActivityFeedbackEndpointAccess,
8
15
  } from './executor.js';
9
16
  import { ActivityFeedbackOrchestrator } from './orchestrator.js';
10
17
  import { ActivityFeedbackPolicy } from './policy.js';
11
18
 
12
- export function bindActivityFeedbackToAIEvents(
13
- root: Plugin['root'],
19
+ export function createActivityFeedbackAIEventHandlers(
14
20
  orchestrator: ActivityFeedbackOrchestrator,
15
- ): () => void {
16
- return subscribeAIEvents(root, {
21
+ ): AIEventHandlers {
22
+ return {
17
23
  onQueuedStart: (payload) => {
18
24
  if (!isActivityFeedbackEnabled(payload, 'queued')) return;
19
25
  return orchestrator.startPhase(payload, 'queued', 'activity.queued.start');
@@ -26,7 +32,13 @@ export function bindActivityFeedbackToAIEvents(
26
32
  onProcessingStart: async (payload) => {
27
33
  if (!isActivityFeedbackEnabled(payload, 'active')) return;
28
34
  await orchestrator.stopPhase(payload, 'queued', 'processing.start');
35
+ if (typeof payload.iterations === 'number' && payload.iterations > 1) {
36
+ await orchestrator.stopPhase(payload, 'thinking', 'processing.iteration');
37
+ }
29
38
  await orchestrator.startPhase(payload, 'active', 'processing.start');
39
+ if (typeof payload.iterations === 'number' && payload.iterations > 1 && payload.content?.trim()) {
40
+ await orchestrator.updatePhaseText(payload, 'active', payload.content.trim());
41
+ }
30
42
  },
31
43
 
32
44
  onTypingStart: async () => {},
@@ -44,9 +56,10 @@ export function bindActivityFeedbackToAIEvents(
44
56
  await orchestrator.stopPhase(payload, 'active', 'processing.error');
45
57
  },
46
58
 
47
- onTypingStop: (payload) => {
59
+ onTypingStop: async (payload) => {
48
60
  if (!isActivityFeedbackEnabled(payload, 'active')) return;
49
- return orchestrator.stopPhase(payload, 'active', 'typing.stop');
61
+ await orchestrator.stopPhase(payload, 'thinking', 'typing.stop');
62
+ await orchestrator.stopPhase(payload, 'active', 'typing.stop');
50
63
  },
51
64
 
52
65
  onThinking: async (payload) => {
@@ -57,46 +70,177 @@ export function bindActivityFeedbackToAIEvents(
57
70
  await orchestrator.updateThinkingText(payload, payload.thinking);
58
71
  },
59
72
 
73
+ onToolCall: async (payload) => {
74
+ if (!isActivityFeedbackEnabled(payload, 'active') || !payload.toolName?.trim()) return;
75
+ const text = `调用工具:${payload.toolName.trim()}…`;
76
+ await orchestrator.updatePhaseText(payload, 'thinking', text);
77
+ await orchestrator.updatePhaseText(payload, 'active', text);
78
+ },
79
+
80
+ onToolResult: async (payload) => {
81
+ if (!isActivityFeedbackEnabled(payload, 'active') || !payload.toolName?.trim()) return;
82
+ const outcome = payload.status === 'error' || payload.error ? '未完成' : '已完成';
83
+ const text = `工具 ${payload.toolName.trim()} ${outcome},继续处理…`;
84
+ await orchestrator.updatePhaseText(payload, 'thinking', text);
85
+ await orchestrator.updatePhaseText(payload, 'active', text);
86
+ },
87
+
60
88
  onSubagentStart: async (payload) => {
61
89
  if (!isActivityFeedbackEnabled(payload, 'thinking')) return;
90
+ // The subagent has an isolated session key; remove its own active placeholder
91
+ // before switching to the richer thinking state.
62
92
  await orchestrator.stopPhase(payload, 'active', 'subagent.start');
63
93
  await orchestrator.startPhase(payload, 'thinking', 'subagent.start');
64
- const label = payload.label
65
- ? `🔍 子任务执行中: ${payload.label}...`
66
- : '🔍 子 agent 处理中...';
67
- await orchestrator.updateThinkingText(payload, label);
94
+ const tag = payload.agentId?.trim() || 'subagent';
95
+ const label = payload.label?.trim()
96
+ ? `子任务执行中: ${payload.label.trim()}...`
97
+ : '思考中...';
98
+ await orchestrator.updateThinkingText(payload, `[${tag}] ${label}`);
68
99
  },
69
100
 
70
101
  onSubagentFinish: async (payload) => {
71
- if (!isActivityFeedbackEnabled(payload, 'active')) return;
72
- await orchestrator.stopPhase(payload, 'thinking', 'subagent.finish');
73
- await orchestrator.startPhase(payload, 'active', 'subagent.finish');
102
+ if (isActivityFeedbackEnabled(payload, 'thinking')) {
103
+ await orchestrator.stopPhase(payload, 'thinking', 'subagent.finish');
104
+ }
105
+ if (isActivityFeedbackEnabled(payload, 'active')) {
106
+ await orchestrator.stopPhase(payload, 'active', 'subagent.finish');
107
+ }
74
108
  },
75
109
 
76
110
  onScheduleStart: (payload) => orchestrator.startPhase(payload, 'schedule_start', 'schedule.start'),
77
- onScheduleFinish: (payload) => orchestrator.stopPhase(payload, 'schedule_start', 'schedule.finish'),
111
+ onScheduleFinish: async (payload) => {
112
+ await orchestrator.stopPhase(payload, 'schedule_start', 'schedule.finish');
113
+ await orchestrator.showTransientPhase(payload, 'schedule_finish', 'schedule.finish');
114
+ },
78
115
  onScheduleError: async (payload) => {
79
116
  await orchestrator.stopPhase(payload, 'schedule_start', 'schedule.error');
80
- await orchestrator.startPhase(payload, 'schedule_error', 'schedule.error');
117
+ await orchestrator.showTransientPhase(payload, 'schedule_error', 'schedule.error');
81
118
  },
82
- });
119
+ };
83
120
  }
84
121
 
85
- export function mountActivityFeedbackService(
86
- plugin: Plugin,
122
+ /**
123
+ * Plugin Runtime path: subscribe on module-level `activityFeedbackAiBus`
124
+ * (fed by ZhinAgentEventEmitter.emit). No usePlugin / Adapter inject.
125
+ */
126
+ export function bindActivityFeedbackToAIEventBus(
87
127
  orchestrator: ActivityFeedbackOrchestrator,
88
- ): void {
89
- const dispose = bindActivityFeedbackToAIEvents(plugin.root, orchestrator);
90
- plugin.onDispose(() => {
91
- plugin.logger.debug('[ActivityFeedback] Disposing binder');
92
- dispose();
93
- });
128
+ admission: GenerationAdmissionGate,
129
+ runWithView: <T>(operation: () => Promise<T>) => Promise<T>,
130
+ ): () => Promise<void> {
131
+ let stopWatchingRetirement: (() => void) | undefined;
132
+ let cleanupNeedsGenerationView = false;
133
+ const watchRetirement = () => {
134
+ if (stopWatchingRetirement) return;
135
+ cleanupNeedsGenerationView = true;
136
+ stopWatchingRetirement = admission.onDeactivate(() => {
137
+ void close();
138
+ });
139
+ };
140
+ const serialized = createGenerationSerializedTarget(
141
+ activityFeedbackAiBus,
142
+ admission,
143
+ runWithView,
144
+ watchRetirement,
145
+ );
146
+ const unsubscribe = subscribeAIEventsOnTarget(
147
+ serialized.target,
148
+ createActivityFeedbackAIEventHandlers(orchestrator),
149
+ );
150
+ let shutdown: Promise<void> | undefined;
151
+ function close(): Promise<void> {
152
+ if (shutdown) return shutdown;
153
+ const cleanup = async () => {
154
+ unsubscribe();
155
+ await serialized.close();
156
+ await orchestrator.dispose();
157
+ };
158
+ // Retirement is published before SnapshotStore changes its current pointer.
159
+ // Enter the IM view synchronously here so timers, native-typing keepalives,
160
+ // and final reaction/message cleanup all remain on this generation's Endpoint.
161
+ // A candidate rolled back before admitting any event has no generation-local
162
+ // feedback state, and its view is unavailable while Root is still idle.
163
+ shutdown = cleanupNeedsGenerationView ? runWithView(cleanup) : cleanup();
164
+ return shutdown;
165
+ }
166
+ return async () => {
167
+ stopWatchingRetirement?.();
168
+ await close();
169
+ };
94
170
  }
95
171
 
172
+ /** Generation-local ordering: one queue per IM session, no module-level runtime state. */
173
+ function createGenerationSerializedTarget(
174
+ source: AIEventTarget,
175
+ admission: GenerationAdmissionGate,
176
+ runWithView: <T>(operation: () => Promise<T>) => Promise<T>,
177
+ onAdmitted: () => void,
178
+ ): {
179
+ target: AIEventTarget;
180
+ close(): Promise<void>;
181
+ } {
182
+ const tails = new Map<string, Promise<void>>();
183
+ const wrappers = new Map<(payload: AIEventPayload) => void | Promise<void>,
184
+ (payload: AIEventPayload) => Promise<void>>();
185
+ let closed = false;
186
+ const target: AIEventTarget = {
187
+ on(event, listener) {
188
+ const wrapped = async (payload: AIEventPayload) => {
189
+ const release = admission.acquire();
190
+ if (!release) return;
191
+ onAdmitted();
192
+ try {
193
+ await runWithView(async () => {
194
+ const key = payload.sessionId || '__global__';
195
+ const previous = tails.get(key);
196
+ const run = async () => {
197
+ if (!closed) await listener(payload);
198
+ };
199
+ const current = previous
200
+ ? previous.catch(() => undefined).then(run)
201
+ : run();
202
+ tails.set(key, current);
203
+ try {
204
+ await current;
205
+ } finally {
206
+ if (tails.get(key) === current) tails.delete(key);
207
+ }
208
+ });
209
+ } finally {
210
+ release();
211
+ }
212
+ };
213
+ wrappers.set(listener, wrapped);
214
+ return source.on(event, wrapped);
215
+ },
216
+ off(event, listener) {
217
+ const wrapped = wrappers.get(listener);
218
+ if (!wrapped) return source.off(event, listener);
219
+ wrappers.delete(listener);
220
+ return source.off(event, wrapped);
221
+ },
222
+ };
223
+ return {
224
+ target,
225
+ async close() {
226
+ closed = true;
227
+ const pending = [...tails.values()];
228
+ await Promise.allSettled(pending);
229
+ tails.clear();
230
+ wrappers.clear();
231
+ },
232
+ };
233
+ }
234
+
235
+ export type ActivityFeedbackLogger = {
236
+ debug: (msg: string, ...args: unknown[]) => void;
237
+ error: (msg: string, ...args: unknown[]) => void;
238
+ };
239
+
96
240
  export interface CreateActivityFeedbackOrchestratorOptions {
97
241
  serviceConfig: ActivityFeedbackServiceConfig;
98
242
  access: ActivityFeedbackEndpointAccess;
99
- logger: Plugin['logger'];
243
+ logger: ActivityFeedbackLogger;
100
244
  }
101
245
 
102
246
  export function createActivityFeedbackOrchestrator(
@@ -107,13 +251,15 @@ export function createActivityFeedbackOrchestrator(
107
251
  return new ActivityFeedbackOrchestrator(policy, executor, options.logger);
108
252
  }
109
253
 
110
- export function createActivityFeedbackOrchestratorFromPlugin(
111
- plugin: Plugin,
254
+ /** Runtime: prefer OutboundHost-backed access; else noop until Host wires outbound. */
255
+ export function createActivityFeedbackOrchestratorForRuntime(
112
256
  serviceConfig: ActivityFeedbackServiceConfig,
257
+ logger: ActivityFeedbackLogger,
258
+ access: ActivityFeedbackEndpointAccess = createNoopEndpointAccess(),
113
259
  ): ActivityFeedbackOrchestrator {
114
260
  return createActivityFeedbackOrchestrator({
115
261
  serviceConfig,
116
- access: createRootEndpointAccess(plugin.root),
117
- logger: plugin.logger,
262
+ access,
263
+ logger,
118
264
  });
119
265
  }
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ActivityFeedbackConfig } from '@zhin.js/agent';
2
2
 
3
- /** 顶层 `activityFeedback` 配置(zhin.config.yml,与 endpoints 解耦) */
3
+ /** Plugin Runtime 实例配置(`plugins.<instanceKey>`,与 Adapter endpoint 配置解耦)。 */
4
4
  export interface ActivityFeedbackServiceConfig {
5
5
  enabled?: boolean;
6
6
  defaults?: ActivityFeedbackConfig;
@@ -66,7 +66,7 @@ export function loadActivityFeedbackServiceConfig(
66
66
  export function resolveActivityFeedbackForTarget(
67
67
  service: ActivityFeedbackServiceConfig,
68
68
  platform: string,
69
- endpointId: string,
69
+ endpointKey: string,
70
70
  ): ActivityFeedbackConfig | undefined {
71
71
  if (service.enabled === false) {
72
72
  return { enabled: false };
@@ -74,6 +74,6 @@ export function resolveActivityFeedbackForTarget(
74
74
  return mergeActivityFeedbackLayers(
75
75
  service.defaults,
76
76
  service.platforms?.[platform],
77
- service.endpoints?.[`${platform}:${endpointId}`],
77
+ service.endpoints?.[`${platform}:${endpointKey}`],
78
78
  );
79
79
  }