@ray-js/t-agent-plugin-aistream 0.2.7-beta.9 → 0.2.8-beta.1

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.
@@ -7,7 +7,7 @@ import { EmitterEvent, generateId, safeParseJSON, StreamResponse } from '@ray-js
7
7
  import { tryCatch } from './misc';
8
8
  import { AIStreamError, transformErrorCode } from './errors';
9
9
  import logger from './logger';
10
- import { deepMerge } from './object';
10
+ import { deepmerge } from '@ray-js/t-agent';
11
11
  const mimeTypeToFormatMap = {
12
12
  'video/mp4': FileFormat.MP4,
13
13
  'text/json': FileFormat.JSON,
@@ -42,6 +42,7 @@ export function sendBlocksToAIStream(params) {
42
42
  });
43
43
  const stream = new ReadableStream({
44
44
  async start(controller) {
45
+ var _cleanupPreEventListe2;
45
46
  const enqueue = part => {
46
47
  if (signal !== null && signal !== void 0 && signal.aborted || canceled || closed) {
47
48
  return;
@@ -84,22 +85,28 @@ export function sendBlocksToAIStream(params) {
84
85
  });
85
86
  };
86
87
  let pendingCancel = false;
88
+ // phase-1: 事件创建前监听 confirm/cancel,用于捕获"边界竞态"时的取消意图
89
+ // 使用具名函数以便在 startEvent 后统一解绑,避免监听器残留在 audioEmitter 上
90
+ let cleanupPreEventListeners;
87
91
  if (audioEmitter) {
88
- audioEmitter.addEventListener('confirm', () => {
89
- // 在确认发送时,如果事件还没创建,则取消发送
92
+ const onPreEventConfirm = () => {
90
93
  if (!event) {
91
94
  logger.debug('sendBlocksToAIStream audioEmitter confirm before event start');
92
- // event 留到后面再关
93
95
  pendingCancel = true;
94
96
  }
95
- });
96
- audioEmitter.addEventListener('cancel', () => {
97
+ };
98
+ const onPreEventCancel = () => {
97
99
  if (!event) {
98
100
  logger.debug('sendBlocksToAIStream audioEmitter cancel before event start');
99
- // event 留到后面再关
100
101
  pendingCancel = true;
101
102
  }
102
- });
103
+ };
104
+ audioEmitter.addEventListener('confirm', onPreEventConfirm);
105
+ audioEmitter.addEventListener('cancel', onPreEventCancel);
106
+ cleanupPreEventListeners = () => {
107
+ audioEmitter.removeEventListener('confirm', onPreEventConfirm);
108
+ audioEmitter.removeEventListener('cancel', onPreEventCancel);
109
+ };
103
110
  }
104
111
  const chatAttributes = {
105
112
  'processing.interrupt': 'false',
@@ -109,7 +116,7 @@ export function sendBlocksToAIStream(params) {
109
116
  if (getUserData) {
110
117
  eventUserData = await getUserData();
111
118
  }
112
- const userData = deepMerge({
119
+ const userData = deepmerge({
113
120
  chatAttributes
114
121
  }, eventUserData);
115
122
  let error;
@@ -124,7 +131,10 @@ export function sendBlocksToAIStream(params) {
124
131
  }]
125
132
  }));
126
133
  if (error) {
134
+ var _cleanupPreEventListe;
127
135
  emitError(error);
136
+ // startEvent 失败,phase-1 监听已无用,立即解绑
137
+ (_cleanupPreEventListe = cleanupPreEventListeners) === null || _cleanupPreEventListe === void 0 || _cleanupPreEventListe();
128
138
  if (audioEmitter) {
129
139
  audioEmitter.dispatchEvent(new EmitterEvent('error', {
130
140
  detail: error
@@ -133,6 +143,9 @@ export function sendBlocksToAIStream(params) {
133
143
  close();
134
144
  return;
135
145
  }
146
+
147
+ // startEvent 已成功(event 已创建),phase-1 监听完成使命,解绑
148
+ (_cleanupPreEventListe2 = cleanupPreEventListeners) === null || _cleanupPreEventListe2 === void 0 || _cleanupPreEventListe2();
136
149
  if (signal !== null && signal !== void 0 && signal.aborted || pendingCancel) {
137
150
  logger.debug('sendBlocksToAIStream pendingCancel aborted');
138
151
  abort();
@@ -306,20 +319,40 @@ export function sendBlocksToAIStream(params) {
306
319
  const s = event.stream({
307
320
  type: 'audio'
308
321
  });
309
- audioEmitter.addEventListener('confirm', async () => {
322
+ let audioDone = false;
323
+
324
+ // cleanupPostEventListeners 先初始化为 noop,让两个 handler 可在闭包里安全引用
325
+ // 后面赋值为真正的清理函数
326
+ let cleanupPostEventListeners = () => {};
327
+ const onPostEventConfirm = async () => {
328
+ // 任一监听触发后,立即同时解绑两者,防止另一个因偶发二次触发产生副作用
329
+ cleanupPostEventListeners();
310
330
  if (!canceled) {
311
331
  await s.stop();
312
332
  }
313
- resolve();
314
- });
315
- audioEmitter.addEventListener('cancel', async () => {
333
+ if (!audioDone) {
334
+ audioDone = true;
335
+ resolve();
336
+ }
337
+ };
338
+ const onPostEventCancel = async () => {
339
+ cleanupPostEventListeners();
316
340
  if (!canceled) {
317
341
  await s.stop();
318
342
  }
319
343
  logger.debug('sendBlocksToAIStream audio cancel aborted');
320
344
  abort();
321
- resolve();
322
- });
345
+ if (!audioDone) {
346
+ audioDone = true;
347
+ resolve();
348
+ }
349
+ };
350
+ cleanupPostEventListeners = () => {
351
+ audioEmitter.removeEventListener('confirm', onPostEventConfirm);
352
+ audioEmitter.removeEventListener('cancel', onPostEventCancel);
353
+ };
354
+ audioEmitter.addEventListener('confirm', onPostEventConfirm);
355
+ audioEmitter.addEventListener('cancel', onPostEventCancel);
323
356
  s.start();
324
357
  });
325
358
  if (signal !== null && signal !== void 0 && signal.aborted) {
@@ -1,5 +1,5 @@
1
1
  import "core-js/modules/es.json.stringify.js";
2
- import { getAccountInfo, sendEvent } from './ttt';
2
+ import { getAccountInfo, sendTrackEvent } from './ttt';
3
3
  const eventId = 'thing_jKaquNvaV2qMDvTpKcNQvb1ZppBZnlNq';
4
4
  let accountInfoPromise = null;
5
5
  export async function trackEvent(agent_code, type) {
@@ -11,7 +11,7 @@ export async function trackEvent(agent_code, type) {
11
11
  miniProgram
12
12
  } = await accountInfoPromise;
13
13
  try {
14
- await sendEvent({
14
+ await sendTrackEvent({
15
15
  eventId,
16
16
  event: {
17
17
  type,
@@ -1,4 +1,4 @@
1
- import { ApiRequestByAtopParams, ApiRequestByHighwayParams, AudioBody, AuthorizeParams, AuthorizePolicyStatusParams, CanIUseRouterParams, CloseSessionParams, ConnectParams, ConnectStateBody, CreateSessionParams, DeleteRecordListParams, DisconnectParams, EventBody, EventChannelMessageParams, GetAppInfoParams, GetCurrentHomeInfoParams, GetMiniAppConfigParams, GetAccountInfoParams, ImageBody, InsertRecordParams, NavigateToMiniProgramParams, OpenInnerH5Params, OpenMiniWidgetParams, QueryAgentTokenParams, QueryRecordListParams, RecordAmplitudesBody, RegisterChannelParams, RouterParams, SendEventChatBreakParams, SendEventEndParams, SendEventPayloadEndParams, SendEventStartParams, SendImageDataParams, SendTextDataParams, SessionStateBody, StartRecordAndSendAudioDataParams, StopRecordAndSendAudioDataParams, TextBody, UpdateRecordParams, IsConnectedParams, GetNetworkTypeParams, StartPlayAudioParams, InitAudioRecorderParams, StopPlayAudioParams, AudioPlayChangedBody, EventParams } from '../AIStreamTypes';
1
+ import { ApiRequestByAtopParams, ApiRequestByHighwayParams, AudioBody, AuthorizeParams, AuthorizePolicyStatusParams, CanIUseRouterParams, CloseSessionParams, ConnectParams, ConnectStateBody, CreateSessionParams, DeleteRecordListParams, DisconnectParams, EventBody, EventChannelMessageParams, GetAppInfoParams, GetCurrentHomeInfoParams, GetMiniAppConfigParams, GetAccountInfoParams, ImageBody, InsertRecordParams, NavigateToMiniProgramParams, OpenInnerH5Params, OpenMiniWidgetParams, QueryAgentTokenParams, QueryRecordListParams, RecordAmplitudesBody, RegisterChannelParams, RouterParams, SendAIStreamEventParams, SendEventChatBreakParams, SendEventEndParams, SendEventPayloadEndParams, SendEventStartParams, SendImageDataParams, SendTextDataParams, SessionStateBody, StartRecordAndSendAudioDataParams, StopRecordAndSendAudioDataParams, TextBody, UpdateRecordParams, IsConnectedParams, GetNetworkTypeParams, StartPlayAudioParams, InitAudioRecorderParams, StopPlayAudioParams, AudioPlayChangedBody, EventParams } from '../AIStreamTypes';
2
2
  export declare const getMiniAppConfig: (options?: Omit<GetMiniAppConfigParams, "success" | "fail"> | undefined) => Promise<{
3
3
  config: any;
4
4
  }>;
@@ -81,6 +81,7 @@ export declare const sendEventStart: (options?: Omit<SendEventStartParams, "succ
81
81
  export declare const sendEventPayloadEnd: (options?: Omit<SendEventPayloadEndParams, "success" | "fail"> | undefined) => Promise<null>;
82
82
  export declare const sendEventEnd: (options?: Omit<SendEventEndParams, "success" | "fail"> | undefined) => Promise<null>;
83
83
  export declare const sendEventChatBreak: (options?: Omit<SendEventChatBreakParams, "success" | "fail"> | undefined) => Promise<null>;
84
+ export declare const sendEventToAIStream: (options?: Omit<SendAIStreamEventParams, "success" | "fail"> | undefined) => Promise<null>;
84
85
  export declare const startRecordAndSendAudioData: (options?: Omit<StartRecordAndSendAudioDataParams, "success" | "fail"> | undefined) => Promise<null>;
85
86
  export declare const stopRecordAndSendAudioData: (options?: Omit<StopRecordAndSendAudioDataParams, "success" | "fail"> | undefined) => Promise<import("../AIStreamTypes").AIStreamAudioFile | null>;
86
87
  export declare const sendImageData: (options?: Omit<SendImageDataParams, "success" | "fail"> | undefined) => Promise<null>;
@@ -113,4 +114,4 @@ export declare const getNetworkType: (options?: Omit<GetNetworkTypeParams, "succ
113
114
  networkType: import("../AIStreamTypes").NetworkType;
114
115
  signalStrength: number;
115
116
  }>;
116
- export declare const sendEvent: (options?: Omit<EventParams, "success" | "fail"> | undefined) => Promise<null>;
117
+ export declare const sendTrackEvent: (options?: Omit<EventParams, "success" | "fail"> | undefined) => Promise<null>;
package/dist/utils/ttt.js CHANGED
@@ -42,6 +42,7 @@ export const sendEventStart = promisify(ty.aistream.sendEventStart, true);
42
42
  export const sendEventPayloadEnd = promisify(ty.aistream.sendEventPayloadEnd, true);
43
43
  export const sendEventEnd = promisify(ty.aistream.sendEventEnd, true);
44
44
  export const sendEventChatBreak = promisify(ty.aistream.sendEventChatBreak, true);
45
+ export const sendEventToAIStream = promisify(ty.aistream.sendEvent, true);
45
46
  export const startRecordAndSendAudioData = promisify(ty.aistream.startRecordAndSendAudioData, true);
46
47
  export const stopRecordAndSendAudioData = promisify(ty.aistream.stopRecordAndSendAudioData, true);
47
48
 
@@ -92,4 +93,4 @@ export const deleteRecordList = promisify(ty.aistream.deleteRecordList, true);
92
93
  export const updateRecord = promisify(ty.aistream.updateRecord, true);
93
94
  export const insertRecord = promisify(ty.aistream.insertRecord, true);
94
95
  export const getNetworkType = promisify(ty.getNetworkType, true);
95
- export const sendEvent = promisify(ty.event);
96
+ export const sendTrackEvent = promisify(ty.event);
@@ -1,6 +1,6 @@
1
1
  import { AbortSignalObject, ChatAgent, ChatCardObject, ChatMessage, ChatMessageStatus, ChatTile, GetChatPluginHandler, InputBlock } from '@ray-js/t-agent';
2
2
  import { TTTAction } from './utils';
3
- import { AIStreamUserData, ConnectClientType, ReceivedTextSkillPacketBody } from './AIStreamTypes';
3
+ import { AIStreamUserData, ConnectClientType, ReceivedTextSkillPacketBody, SendAIStreamEventParams, SessionEventBody } from './AIStreamTypes';
4
4
  import { ChatHistoryStore, StoredMessageObject } from './ChatHistoryStore';
5
5
  export interface AIStreamOptions {
6
6
  /** client 类型: 1-作为设备代理, 2-作为 App,3-作为开发者(行业 App) */
@@ -63,6 +63,7 @@ export interface AIStreamHooks {
63
63
  }, result: {
64
64
  userData: AIStreamUserData;
65
65
  }) => void;
66
+ onSessionEventReceived: (event: SessionEventBody) => void;
66
67
  }
67
68
  export declare function withAIStream(options?: AIStreamOptions): (agent: ChatAgent) => {
68
69
  hooks: import("hookable").Hookable<AIStreamHooks, import("hookable").HookKeys<AIStreamHooks>>;
@@ -92,6 +93,8 @@ export declare function withAIStream(options?: AIStreamOptions): (agent: ChatAge
92
93
  onSkillsEnd: (fn: AIStreamHooks['onSkillsEnd']) => () => void;
93
94
  onCardsReceived: (fn: AIStreamHooks['onCardsReceived']) => () => void;
94
95
  onUserDataRead: (fn: AIStreamHooks['onUserDataRead']) => () => void;
96
+ onSessionEventReceived: (fn: AIStreamHooks['onSessionEventReceived']) => () => void;
97
+ sendEvent: (params: SendAIStreamEventParams) => Promise<null>;
95
98
  onTTTAction: (fn: AIStreamHooks['onTTTAction']) => () => void;
96
99
  getChatId: () => Promise<string>;
97
100
  };
@@ -1,20 +1,27 @@
1
1
  import _objectWithoutProperties from "@babel/runtime/helpers/esm/objectWithoutProperties";
2
2
  import _objectSpread from "@babel/runtime/helpers/esm/objectSpread2";
3
+ import _wrapAsyncGenerator from "@babel/runtime/helpers/esm/wrapAsyncGenerator";
4
+ import _awaitAsyncGenerator from "@babel/runtime/helpers/esm/awaitAsyncGenerator";
5
+ import _asyncGeneratorDelegate from "@babel/runtime/helpers/esm/asyncGeneratorDelegate";
3
6
  const _excluded = ["message"];
7
+ import "core-js/modules/es.symbol.description.js";
8
+ import "core-js/modules/es.symbol.async-iterator.js";
4
9
  import "core-js/modules/es.array.flat.js";
5
10
  import "core-js/modules/es.array.reverse.js";
6
11
  import "core-js/modules/es.array.unscopables.flat.js";
7
12
  import "core-js/modules/esnext.iterator.constructor.js";
8
13
  import "core-js/modules/esnext.iterator.map.js";
9
14
  import "core-js/modules/web.dom-collections.iterator.js";
15
+ function _asyncIterator(r) { var n, t, o, e = 2; for ("undefined" != typeof Symbol && (t = Symbol.asyncIterator, o = Symbol.iterator); e--;) { if (t && null != (n = r[t])) return n.call(r); if (o && null != (n = r[o])) return new AsyncFromSyncIterator(n.call(r)); t = "@@asyncIterator", o = "@@iterator"; } throw new TypeError("Object is not async iterable"); }
16
+ function AsyncFromSyncIterator(r) { function AsyncFromSyncIteratorContinuation(r) { if (Object(r) !== r) return Promise.reject(new TypeError(r + " is not an object.")); var n = r.done; return Promise.resolve(r.value).then(function (r) { return { value: r, done: n }; }); } return AsyncFromSyncIterator = function (r) { this.s = r, this.n = r.next; }, AsyncFromSyncIterator.prototype = { s: null, n: null, next: function () { return AsyncFromSyncIteratorContinuation(this.n.apply(this.s, arguments)); }, return: function (r) { var n = this.s.return; return void 0 === n ? Promise.resolve({ value: r, done: !0 }) : AsyncFromSyncIteratorContinuation(n.apply(this.s, arguments)); }, throw: function (r) { var n = this.s.return; return void 0 === n ? Promise.reject(r) : AsyncFromSyncIteratorContinuation(n.apply(this.s, arguments)); } }, new AsyncFromSyncIterator(r); }
10
17
  import { BubbleTileStatus, ChatMessageStatus, createHooks, EmitterEvent } from '@ray-js/t-agent';
11
18
  import { messageAppraise } from './utils/apis';
12
- import { getAccountInfo, getCurrentHomeInfo, runTTTAction, sendBlocksToAIStream } from './utils';
19
+ import { getAccountInfo, getCurrentHomeInfo, runTTTAction, sendBlocksToAIStream, sendEventToAIStream } from './utils';
13
20
  import { BizCode, ConnectClientType } from './AIStreamTypes';
14
21
  import { DEFAULT_TOKEN_API, DEFAULT_TOKEN_API_VERSION, globalAIStreamClient } from './global';
15
22
  import logger from './utils/logger';
16
23
  import { ChatHistoryLocalStore } from './ChatHistoryLocalStore';
17
- import { deepMerge } from './utils/object';
24
+ import { deepmerge } from '@ray-js/t-agent';
18
25
  import { trackEvent } from './utils/track';
19
26
  export function withAIStream() {
20
27
  let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
@@ -103,6 +110,9 @@ export function withAIStream() {
103
110
  return result.userData;
104
111
  }
105
112
  });
113
+ streamSession.on('sessionEvent', event => {
114
+ hooks.callHook('onSessionEventReceived', event);
115
+ });
106
116
  await session.set('AIStream.streamSession', streamSession);
107
117
  if (options.earlyStart) {
108
118
  // 故意异步,不阻塞消息列表加载
@@ -248,19 +258,42 @@ export function withAIStream() {
248
258
  await hooks.callHook('onUserDataRead', 'start-event', {
249
259
  blocks
250
260
  }, userDataResult);
251
- const userData = {};
252
- deepMerge(userData, userDataResult.userData);
253
- deepMerge(userData, eventUserData);
261
+ let userData = deepmerge({}, userDataResult.userData);
262
+ userData = deepmerge(userData, eventUserData);
254
263
  return userData;
255
264
  }
256
265
  });
257
- signal === null || signal === void 0 || signal.addEventListener('abort', event => {
258
- logger.debug('withAIStream signal aborted, response.started:', result.response.started);
259
- if (result.response.started) {
260
- result.response.cancel(event.reason);
261
- }
262
- agent.hooks.callHook('onUserAbort', event.reason);
263
- });
266
+ if (signal) {
267
+ const onAbort = event => {
268
+ logger.debug('withAIStream signal aborted, response.started:', result.response.started);
269
+ if (result.response.started) {
270
+ result.response.cancel(event.reason);
271
+ }
272
+ agent.hooks.callHook('onUserAbort', event.reason);
273
+ };
274
+
275
+ // once:true 保证 abort 触发后自动解绑,避免监听器残留
276
+ signal.addEventListener('abort', onAbort, {
277
+ once: true
278
+ });
279
+
280
+ // 正常消费流时(未触发 abort)在 parts() 的 finally 里解绑,防止 signal 长期持有闭包
281
+ const _parts = result.response.parts.bind(result.response);
282
+ result.response.parts = () => {
283
+ const iterable = _parts();
284
+ return {
285
+ [Symbol.asyncIterator]() {
286
+ return _wrapAsyncGenerator(function* () {
287
+ try {
288
+ yield* _asyncGeneratorDelegate(_asyncIterator(iterable), _awaitAsyncGenerator);
289
+ } finally {
290
+ signal.removeEventListener('abort', onAbort);
291
+ }
292
+ })();
293
+ }
294
+ };
295
+ };
296
+ }
264
297
  signal === null || signal === void 0 || signal.throwIfAborted();
265
298
  return result;
266
299
  };
@@ -291,8 +324,18 @@ export function withAIStream() {
291
324
  if (audioEmitter) {
292
325
  let end = false;
293
326
  audioPromise = new Promise((resolve, reject) => {
294
- // 当确认发送时,展示 loading
295
327
  let userMsgShow = false;
328
+
329
+ // onAbort 先定义,让后续终态处理器可引用它来解绑
330
+ const onAbort = () => {
331
+ logger.debug('withAIStream chat agent.chat audioEmitter onAbort');
332
+ audioEmitter.dispatchEvent(new EmitterEvent('cancel'));
333
+ };
334
+ // signal 可选:仅在传入时注册
335
+ signal === null || signal === void 0 || signal.addEventListener('abort', onAbort);
336
+
337
+ // 当确认发送时,展示 loading
338
+
296
339
  audioEmitter.addEventListener('confirm', async () => {
297
340
  logger.debug('withAIStream chat agent.chat audioEmitter onConfirm');
298
341
  if (end) {
@@ -316,6 +359,8 @@ export function withAIStream() {
316
359
  return;
317
360
  }
318
361
  end = true;
362
+ // 终态:解绑 signal 上的 abort 监听,避免 signal 长期持有本轮闭包
363
+ signal === null || signal === void 0 || signal.removeEventListener('abort', onAbort);
319
364
  if (!event.detail.text && userMsgShow) {
320
365
  await userMsg.remove();
321
366
  reject(new Error('No text found in audio event'));
@@ -335,6 +380,8 @@ export function withAIStream() {
335
380
  audioEmitter.addEventListener('cancel', async () => {
336
381
  logger.debug('withAIStream chat agent.chat audioEmitter onCancel');
337
382
  end = true;
383
+ // 终态:解绑 signal 上的 abort 监听
384
+ signal === null || signal === void 0 || signal.removeEventListener('abort', onAbort);
338
385
  // 取消时,有可能 userMsg.persist 还在执行,所以这里不 await,先 reject
339
386
  reject(new Error('User cancel'));
340
387
  if (!response.started && userMsgShow) {
@@ -343,13 +390,11 @@ export function withAIStream() {
343
390
  }, {
344
391
  once: true
345
392
  });
346
- signal.addEventListener('abort', () => {
347
- logger.debug('withAIStream chat agent.chat audioEmitter onAbort');
348
- audioEmitter.dispatchEvent(new EmitterEvent('cancel'));
349
- });
350
393
  audioEmitter.addEventListener('error', async () => {
351
394
  logger.debug('withAIStream chat agent.chat audioEmitter onError');
352
395
  end = true;
396
+ // 终态:解绑 signal 上的 abort 监听
397
+ signal === null || signal === void 0 || signal.removeEventListener('abort', onAbort);
353
398
  reject(new Error('Audio emitter error'));
354
399
  if (userMsgShow && userMsg.status !== ChatMessageStatus.FINISH) {
355
400
  await userMsg.remove();
@@ -616,6 +661,10 @@ export function withAIStream() {
616
661
  onUserDataRead: fn => {
617
662
  return hooks.hook('onUserDataRead', fn);
618
663
  },
664
+ onSessionEventReceived: fn => {
665
+ return hooks.hook('onSessionEventReceived', fn);
666
+ },
667
+ sendEvent: params => sendEventToAIStream(params),
619
668
  onTTTAction: fn => {
620
669
  return hooks.hook('onTTTAction', fn);
621
670
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ray-js/t-agent-plugin-aistream",
3
- "version": "0.2.7-beta.9",
3
+ "version": "0.2.8-beta.1",
4
4
  "author": "Tuya.inc",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -35,5 +35,5 @@
35
35
  "devDependencies": {
36
36
  "@types/url-parse": "^1.4.11"
37
37
  },
38
- "gitHead": "31a0d88f88eddae8bfd802e4861c76cd02c07692"
38
+ "gitHead": "040499ca11aedda3246121f880807da0f8af3cc9"
39
39
  }