@zhin.js/service-activity-feedback 1.0.2 → 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 CHANGED
@@ -3,10 +3,126 @@
3
3
  "type": "object",
4
4
  "additionalProperties": false,
5
5
  "properties": {
6
- "enabled": { "type": "boolean", "default": true },
7
- "defaults": { "type": "object", "additionalProperties": true },
8
- "platforms": { "type": "object", "additionalProperties": true },
9
- "endpoints": { "type": "object", "additionalProperties": true },
10
- "schedule": { "type": "object", "additionalProperties": true }
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
+ }
11
127
  }
12
128
  }
@@ -1,16 +1,16 @@
1
- import type { Plugin } from 'zhin.js';
2
1
  import {
3
- subscribeAIEvents,
4
2
  subscribeAIEventsOnTarget,
5
3
  activityFeedbackAiBus,
6
4
  isActivityFeedbackEnabled,
7
5
  type AIEventHandlers,
6
+ type AIEventPayload,
7
+ type AIEventTarget,
8
8
  } from '@zhin.js/agent';
9
9
  import { loadActivityFeedbackServiceConfig, type ActivityFeedbackServiceConfig } from './config.js';
10
+ import type { GenerationAdmissionGate } from 'zhin.js';
10
11
  import {
11
12
  ActivityFeedbackExecutor,
12
13
  createNoopEndpointAccess,
13
- createRootEndpointAccess,
14
14
  type ActivityFeedbackEndpointAccess,
15
15
  } from './executor.js';
16
16
  import { ActivityFeedbackOrchestrator } from './orchestrator.js';
@@ -32,7 +32,13 @@ export function createActivityFeedbackAIEventHandlers(
32
32
  onProcessingStart: async (payload) => {
33
33
  if (!isActivityFeedbackEnabled(payload, 'active')) return;
34
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
+ }
35
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
+ }
36
42
  },
37
43
 
38
44
  onTypingStart: async () => {},
@@ -50,9 +56,10 @@ export function createActivityFeedbackAIEventHandlers(
50
56
  await orchestrator.stopPhase(payload, 'active', 'processing.error');
51
57
  },
52
58
 
53
- onTypingStop: (payload) => {
59
+ onTypingStop: async (payload) => {
54
60
  if (!isActivityFeedbackEnabled(payload, 'active')) return;
55
- return orchestrator.stopPhase(payload, 'active', 'typing.stop');
61
+ await orchestrator.stopPhase(payload, 'thinking', 'typing.stop');
62
+ await orchestrator.stopPhase(payload, 'active', 'typing.stop');
56
63
  },
57
64
 
58
65
  onThinking: async (payload) => {
@@ -63,61 +70,166 @@ export function createActivityFeedbackAIEventHandlers(
63
70
  await orchestrator.updateThinkingText(payload, payload.thinking);
64
71
  },
65
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
+
66
88
  onSubagentStart: async (payload) => {
67
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.
68
92
  await orchestrator.stopPhase(payload, 'active', 'subagent.start');
69
93
  await orchestrator.startPhase(payload, 'thinking', 'subagent.start');
70
- const label = payload.label
71
- ? `🔍 子任务执行中: ${payload.label}...`
72
- : '🔍 子 agent 处理中...';
73
- 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}`);
74
99
  },
75
100
 
76
101
  onSubagentFinish: async (payload) => {
77
- if (!isActivityFeedbackEnabled(payload, 'active')) return;
78
- await orchestrator.stopPhase(payload, 'thinking', 'subagent.finish');
79
- 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
+ }
80
108
  },
81
109
 
82
110
  onScheduleStart: (payload) => orchestrator.startPhase(payload, 'schedule_start', 'schedule.start'),
83
- 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
+ },
84
115
  onScheduleError: async (payload) => {
85
116
  await orchestrator.stopPhase(payload, 'schedule_start', 'schedule.error');
86
- await orchestrator.startPhase(payload, 'schedule_error', 'schedule.error');
117
+ await orchestrator.showTransientPhase(payload, 'schedule_error', 'schedule.error');
87
118
  },
88
119
  };
89
120
  }
90
121
 
91
- /** Legacy host Plugin path (ALS-aware subscribeAIEvents). */
92
- export function bindActivityFeedbackToAIEvents(
93
- root: Plugin['root'],
94
- orchestrator: ActivityFeedbackOrchestrator,
95
- ): () => void {
96
- return subscribeAIEvents(root, createActivityFeedbackAIEventHandlers(orchestrator));
97
- }
98
-
99
122
  /**
100
123
  * Plugin Runtime path: subscribe on module-level `activityFeedbackAiBus`
101
124
  * (fed by ZhinAgentEventEmitter.emit). No usePlugin / Adapter inject.
102
125
  */
103
126
  export function bindActivityFeedbackToAIEventBus(
104
127
  orchestrator: ActivityFeedbackOrchestrator,
105
- ): () => void {
106
- return subscribeAIEventsOnTarget(
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(
107
141
  activityFeedbackAiBus,
142
+ admission,
143
+ runWithView,
144
+ watchRetirement,
145
+ );
146
+ const unsubscribe = subscribeAIEventsOnTarget(
147
+ serialized.target,
108
148
  createActivityFeedbackAIEventHandlers(orchestrator),
109
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
+ };
110
170
  }
111
171
 
112
- export function mountActivityFeedbackService(
113
- plugin: Plugin,
114
- orchestrator: ActivityFeedbackOrchestrator,
115
- ): void {
116
- const dispose = bindActivityFeedbackToAIEvents(plugin.root, orchestrator);
117
- plugin.onDispose(() => {
118
- plugin.logger.debug('[ActivityFeedback] Disposing binder');
119
- dispose();
120
- });
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
+ };
121
233
  }
122
234
 
123
235
  export type ActivityFeedbackLogger = {
@@ -139,17 +251,6 @@ export function createActivityFeedbackOrchestrator(
139
251
  return new ActivityFeedbackOrchestrator(policy, executor, options.logger);
140
252
  }
141
253
 
142
- export function createActivityFeedbackOrchestratorFromPlugin(
143
- plugin: Plugin,
144
- serviceConfig: ActivityFeedbackServiceConfig,
145
- ): ActivityFeedbackOrchestrator {
146
- return createActivityFeedbackOrchestrator({
147
- serviceConfig,
148
- access: createRootEndpointAccess(plugin.root),
149
- logger: plugin.logger,
150
- });
151
- }
152
-
153
254
  /** Runtime: prefer OutboundHost-backed access; else noop until Host wires outbound. */
154
255
  export function createActivityFeedbackOrchestratorForRuntime(
155
256
  serviceConfig: ActivityFeedbackServiceConfig,
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
  }