@adminforth/bulk-ai-flow 1.1.4 → 1.2.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.
@@ -13,7 +13,7 @@
13
13
  <template #cell:checkboxes="{ item }">
14
14
  <div class="flex items-center justify-center">
15
15
  <Checkbox
16
- v-model="selected[tableColumnsIndexes.findIndex(el => el.label === item.label)].isChecked"
16
+ v-model="selected[tableColumnsIndexes.findIndex(el => el[primaryKey] === item[primaryKey])].isChecked"
17
17
  />
18
18
  </div>
19
19
  </template>
@@ -44,7 +44,7 @@
44
44
  </template>
45
45
  <!-- CUSTOM FIELD TEMPLATES -->
46
46
  <template v-for="n in customFieldNames" :key="n" #[`cell:${n}`]="{ item, column }">
47
- <div v-if="isAiResponseReceived[tableColumnsIndexes.findIndex(el => el[primaryKey] === item[primaryKey])]">
47
+ <div v-if="isAiResponseReceivedAnalize[tableColumnsIndexes.findIndex(el => el[primaryKey] === item[primaryKey])] && !isInColumnImage(n)">
48
48
  <div v-if="isInColumnEnum(n)">
49
49
  <Select
50
50
  :options="convertColumnEnumToSelectOptions(props.meta.columnEnums, n)"
@@ -77,6 +77,35 @@
77
77
  />
78
78
  </div>
79
79
  </div>
80
+
81
+ <div v-else-if="isAiResponseReceivedImage[tableColumnsIndexes.findIndex(el => el[primaryKey] === item[primaryKey])]">
82
+ <div v-if="isInColumnImage(n)">
83
+ <div class="mt-2 flex items-center justify-center gap-2">
84
+ <img
85
+ :src="selected[tableColumnsIndexes.findIndex(el => el[primaryKey] === item[primaryKey])][n]"
86
+ class="w-20 h-20 object-cover rounded cursor-pointer border hover:border-blue-500 transition"
87
+ @click="() => {openGenerationCarousel[tableColumnsIndexes.findIndex(el => el[primaryKey] === item[primaryKey])][n] = true}"
88
+ />
89
+ </div>
90
+ <div>
91
+ <GenerationCarousel
92
+ v-if="openGenerationCarousel[tableColumnsIndexes.findIndex(el => el[primaryKey] === item[primaryKey])][n]"
93
+ :images="selected[tableColumnsIndexes.findIndex(el => el[primaryKey] === item[primaryKey])][n]"
94
+ :recordId="item[primaryKey]"
95
+ :meta="props.meta"
96
+ :fieldName="n"
97
+ @error="handleError"
98
+ @close="openGenerationCarousel[tableColumnsIndexes.findIndex(el => el[primaryKey] === item[primaryKey])][n] = false"
99
+ @selectImage="updateSelectedImage"
100
+ />
101
+ </div>
102
+ </div>
103
+ </div>
104
+
105
+ <div v-else-if="isInColumnImage(n)">
106
+ <Skeleton type="image" class="w-20 h-20" />
107
+ </div>
108
+
80
109
  <div v-else>
81
110
  <Skeleton class="w-full h-6" />
82
111
  </div>
@@ -89,6 +118,7 @@
89
118
  import { ref, nextTick, watch } from 'vue'
90
119
  import mediumZoom from 'medium-zoom'
91
120
  import { Select, Input, Textarea, Table, Checkbox, Skeleton, Toggle } from '@/afcl'
121
+ import GenerationCarousel from './imageGenerationCarousel.vue'
92
122
 
93
123
  const props = defineProps<{
94
124
  meta: any,
@@ -97,13 +127,20 @@ const props = defineProps<{
97
127
  customFieldNames: any,
98
128
  tableColumnsIndexes: any,
99
129
  selected: any,
100
- isAiResponseReceived: boolean[],
101
- primaryKey: any
130
+ isAiResponseReceivedAnalize: boolean[],
131
+ isAiResponseReceivedImage: boolean[],
132
+ primaryKey: any,
133
+ openGenerationCarousel: any
134
+ isError: boolean,
135
+ errorMessage: string
102
136
  }>();
137
+ const emit = defineEmits(['error']);
138
+
103
139
 
104
140
  const zoomedImage = ref(null)
105
141
  const zoomedImg = ref(null)
106
142
 
143
+
107
144
  function zoomImage(img) {
108
145
  zoomedImage.value = img
109
146
  }
@@ -131,6 +168,10 @@ function isInColumnEnum(key: string): boolean {
131
168
  return true;
132
169
  }
133
170
 
171
+ function isInColumnImage(key: string): boolean {
172
+ return props.meta.outputImageFields?.includes(key) || false;
173
+ }
174
+
134
175
  function convertColumnEnumToSelectOptions(columnEnumArray: any[], key: string) {
135
176
  const col = columnEnumArray.find(c => c.name === key);
136
177
  if (!col) return [];
@@ -140,4 +181,15 @@ function convertColumnEnumToSelectOptions(columnEnumArray: any[], key: string) {
140
181
  }));
141
182
  }
142
183
 
184
+ function updateSelectedImage(image: string, id: any, fieldName: string) {
185
+ props.selected[props.tableColumnsIndexes.findIndex(el => el[props.primaryKey] === id)][fieldName] = image;
186
+ }
187
+
188
+ function handleError({ isError, errorMessage }) {
189
+ emit('error', {
190
+ isError,
191
+ errorMessage
192
+ });
193
+ }
194
+
143
195
  </script>
package/dist/index.js CHANGED
@@ -9,10 +9,14 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
9
9
  };
10
10
  import { AdminForthPlugin, Filters } from "adminforth";
11
11
  import Handlebars from 'handlebars';
12
+ import { RateLimiter } from "adminforth";
12
13
  export default class BulkAiFlowPlugin extends AdminForthPlugin {
13
14
  constructor(options) {
14
15
  super(options, import.meta.url);
15
16
  this.options = options;
17
+ // for calculating average time
18
+ this.totalCalls = 0;
19
+ this.totalDuration = 0;
16
20
  }
17
21
  // Compile Handlebars templates in outputFields using record fields as context
18
22
  compileOutputFieldsTemplates(record) {
@@ -28,6 +32,41 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
28
32
  }
29
33
  return compiled;
30
34
  }
35
+ compileOutputFieldsTemplatesNoImage(record) {
36
+ const compiled = {};
37
+ for (const [key, templateStr] of Object.entries(this.options.fillPlainFields)) {
38
+ try {
39
+ const tpl = Handlebars.compile(String(templateStr));
40
+ compiled[key] = tpl(record);
41
+ }
42
+ catch (_a) {
43
+ compiled[key] = String(templateStr);
44
+ }
45
+ }
46
+ return compiled;
47
+ }
48
+ compileGenerationFieldTemplates(record) {
49
+ const compiled = {};
50
+ for (const key in this.options.generateImages) {
51
+ try {
52
+ const tpl = Handlebars.compile(String(this.options.generateImages[key].prompt));
53
+ compiled[key] = tpl(record);
54
+ }
55
+ catch (_a) {
56
+ compiled[key] = String(this.options.generateImages[key].prompt);
57
+ }
58
+ }
59
+ return compiled;
60
+ }
61
+ checkRateLimit(fieldNameRateLimit, headers) {
62
+ if (fieldNameRateLimit) {
63
+ // rate limit
64
+ const { error } = RateLimiter.checkRateLimit(this.pluginInstanceId, fieldNameRateLimit, this.adminforth.auth.getClientIp(headers));
65
+ if (error) {
66
+ return { error: "Rate limit exceeded" };
67
+ }
68
+ }
69
+ }
31
70
  modifyResourceConfig(adminforth, resourceConfig) {
32
71
  const _super = Object.create(null, {
33
72
  modifyResourceConfig: { get: () => super.modifyResourceConfig }
@@ -37,21 +76,63 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
37
76
  //check if options names are provided
38
77
  const columns = this.resourceConfig.columns;
39
78
  let columnEnums = [];
40
- for (const [key, value] of Object.entries(this.options.fillFieldsFromImages)) {
41
- const column = columns.find(c => c.name.toLowerCase() === key.toLowerCase());
42
- if (column) {
43
- if (column.enum) {
44
- this.options.fillFieldsFromImages[key] = `${value} Select ${key} from the list (USE ONLY VALUE FIELD. USE ONLY VALUES FROM THIS LIST): ${JSON.stringify(column.enum)}`;
45
- columnEnums.push({
46
- name: key,
47
- enum: column.enum,
48
- });
79
+ if (this.options.fillFieldsFromImages) {
80
+ if (!this.options.attachFiles) {
81
+ throw new Error('⚠️ attachFiles function must be provided in options when fillFieldsFromImages is used');
82
+ }
83
+ if (!this.options.visionAdapter) {
84
+ throw new Error('⚠️ visionAdapter must be provided in options when fillFieldsFromImages is used');
85
+ }
86
+ for (const [key, value] of Object.entries((this.options.fillFieldsFromImages))) {
87
+ const column = columns.find(c => c.name.toLowerCase() === key.toLowerCase());
88
+ if (column) {
89
+ if (column.enum) {
90
+ this.options.fillFieldsFromImages[key] = `${value} Select ${key} from the list (USE ONLY VALUE FIELD. USE ONLY VALUES FROM THIS LIST): ${JSON.stringify(column.enum)}`;
91
+ columnEnums.push({
92
+ name: key,
93
+ enum: column.enum,
94
+ });
95
+ }
96
+ }
97
+ else {
98
+ throw new Error(`⚠️ No column found for key "${key}"`);
49
99
  }
50
100
  }
51
- else {
52
- throw new Error(`⚠️ No column found for key "${key}"`);
101
+ }
102
+ if (this.options.fillPlainFields) {
103
+ if (!this.options.textCompleteAdapter) {
104
+ throw new Error('⚠️ textCompleteAdapter must be provided in options when fillPlainFields is used');
105
+ }
106
+ for (const [key, value] of Object.entries((this.options.fillPlainFields))) {
107
+ const column = columns.find(c => c.name.toLowerCase() === key.toLowerCase());
108
+ if (column) {
109
+ if (column.enum) {
110
+ this.options.fillPlainFields[key] = `${value} Select ${key} from the list (USE ONLY VALUE FIELD. USE ONLY VALUES FROM THIS LIST): ${JSON.stringify(column.enum)}`;
111
+ columnEnums.push({
112
+ name: key,
113
+ enum: column.enum,
114
+ });
115
+ }
116
+ }
117
+ else {
118
+ throw new Error(`⚠️ No column found for key "${key}"`);
119
+ }
120
+ }
121
+ }
122
+ if (this.options.generateImages && !this.options.imageGenerationAdapter) {
123
+ for (const [key, value] of Object.entries(this.options.generateImages)) {
124
+ if (!this.options.generateImages[key].adapter) {
125
+ throw new Error(`⚠️ No image generation adapter found for key "${key}"`);
126
+ }
53
127
  }
54
128
  }
129
+ const outputImageFields = [];
130
+ if (this.options.generateImages) {
131
+ for (const [key, value] of Object.entries(this.options.generateImages)) {
132
+ outputImageFields.push(key);
133
+ }
134
+ }
135
+ const outputImagesPluginInstanceIds = {};
55
136
  //check if Upload plugin is installed on all attachment fields
56
137
  if (this.options.generateImages) {
57
138
  for (const [key, value] of Object.entries(this.options.generateImages)) {
@@ -70,18 +151,25 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
70
151
  Please configure adapter in such way that it will store objects publicly (e.g. for S3 use 'public-read' ACL).
71
152
  `);
72
153
  }
73
- this.uploadPlugin = plugin;
154
+ outputImagesPluginInstanceIds[key] = plugin.pluginInstanceId;
74
155
  }
75
156
  }
157
+ const outputFields = Object.assign(Object.assign(Object.assign({}, this.options.fillFieldsFromImages), this.options.fillPlainFields), (this.options.generateImages || {}));
76
158
  const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
77
159
  const pageInjection = {
78
160
  file: this.componentPath('visionAction.vue'),
79
161
  meta: {
80
162
  pluginInstanceId: this.pluginInstanceId,
81
- outputFields: this.options.fillFieldsFromImages,
163
+ outputFields: outputFields,
82
164
  actionName: this.options.actionName,
83
165
  columnEnums: columnEnums,
166
+ outputImageFields: outputImageFields,
167
+ outputPlainFields: this.options.fillPlainFields,
84
168
  primaryKey: primaryKeyColumn.name,
169
+ outputImagesPluginInstanceIds: outputImagesPluginInstanceIds,
170
+ isFieldsForAnalizeFromImages: this.options.fillFieldsFromImages ? Object.keys(this.options.fillFieldsFromImages).length > 0 : false,
171
+ isFieldsForAnalizePlain: this.options.fillPlainFields ? Object.keys(this.options.fillPlainFields).length > 0 : false,
172
+ isImageGeneration: this.options.generateImages ? Object.keys(this.options.generateImages).length > 0 : false
85
173
  }
86
174
  };
87
175
  if (!this.resourceConfig.options.pageInjections) {
@@ -139,6 +227,36 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
139
227
  return { result };
140
228
  })
141
229
  });
230
+ server.endpoint({
231
+ method: 'POST',
232
+ path: `/plugin/${this.pluginInstanceId}/analyze_no_images`,
233
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers }) {
234
+ const selectedIds = body.selectedIds || [];
235
+ const tasks = selectedIds.map((ID) => __awaiter(this, void 0, void 0, function* () {
236
+ // Fetch the record using the provided ID
237
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
238
+ const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(primaryKeyColumn.name, ID)]);
239
+ //create prompt for OpenAI
240
+ const compiledOutputFields = this.compileOutputFieldsTemplatesNoImage(record);
241
+ const prompt = `Analyze the following fields and return a single JSON in format like: {'param1': 'value1', 'param2': 'value2'}.
242
+ Do NOT return array of objects. Do NOT include any Markdown, code blocks, explanations, or extra text. Only return valid JSON.
243
+ Each object must contain the following fields: ${JSON.stringify(compiledOutputFields)} Use the exact field names.
244
+ If it's number field - return only number.`;
245
+ //send prompt to OpenAI and get response
246
+ const { content: chatResponse, finishReason } = yield this.options.textCompleteAdapter.complete(prompt, [], 500);
247
+ const resp = chatResponse.response;
248
+ const topLevelError = chatResponse.error;
249
+ if (topLevelError || (resp === null || resp === void 0 ? void 0 : resp.error)) {
250
+ throw new Error(`ERROR: ${JSON.stringify(topLevelError || (resp === null || resp === void 0 ? void 0 : resp.error))}`);
251
+ }
252
+ //parse response and update record
253
+ const resData = JSON.parse(chatResponse);
254
+ return resData;
255
+ }));
256
+ const result = yield Promise.all(tasks);
257
+ return { result };
258
+ })
259
+ });
142
260
  server.endpoint({
143
261
  method: 'POST',
144
262
  path: `/plugin/${this.pluginInstanceId}/get_records`,
@@ -161,7 +279,9 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
161
279
  let images = [];
162
280
  if (body.body.record) {
163
281
  for (const record of body.body.record) {
164
- images.push(yield this.options.attachFiles({ record: record }));
282
+ if (this.options.attachFiles) {
283
+ images.push(yield this.options.attachFiles({ record: record }));
284
+ }
165
285
  }
166
286
  }
167
287
  return {
@@ -175,12 +295,170 @@ export default class BulkAiFlowPlugin extends AdminForthPlugin {
175
295
  handler: (body) => __awaiter(this, void 0, void 0, function* () {
176
296
  const selectedIds = body.body.selectedIds || [];
177
297
  const fieldsToUpdate = body.body.fields || {};
178
- const updates = selectedIds.map((ID, idx) => this.adminforth
179
- .resource(this.resourceConfig.resourceId)
180
- .update(ID, fieldsToUpdate[idx]));
298
+ const outputImageFields = [];
299
+ if (this.options.generateImages) {
300
+ for (const [key, value] of Object.entries(this.options.generateImages)) {
301
+ outputImageFields.push(key);
302
+ }
303
+ }
304
+ const primaryKeyColumn = this.resourceConfig.columns.find((col) => col.primaryKey);
305
+ const updates = selectedIds.map((ID, idx) => __awaiter(this, void 0, void 0, function* () {
306
+ const oldRecord = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(primaryKeyColumn.name, ID)]);
307
+ for (const [key, value] of Object.entries(outputImageFields)) {
308
+ const columnPlugin = this.adminforth.activatedPlugins.find(p => p.resourceConfig.resourceId === this.resourceConfig.resourceId &&
309
+ p.pluginOptions.pathColumnName === value);
310
+ if (columnPlugin) {
311
+ if (columnPlugin.pluginOptions.storageAdapter.objectCanBeAccesedPublicly()) {
312
+ if (oldRecord[value]) {
313
+ // put tag to delete old file
314
+ try {
315
+ yield columnPlugin.pluginOptions.storageAdapter.markKeyForDeletation(oldRecord[value]);
316
+ }
317
+ catch (e) {
318
+ // file might be e.g. already deleted, so we catch error
319
+ console.error(`Error setting tag to true for object ${oldRecord[value]}. File will not be auto-cleaned up`, e);
320
+ }
321
+ }
322
+ if (fieldsToUpdate[idx][key] !== null) {
323
+ // remove tag from new file
324
+ // in this case we let it crash if it fails: this is a new file which just was uploaded.
325
+ yield columnPlugin.pluginOptions.storageAdapter.markKeyForNotDeletation(fieldsToUpdate[idx][value]);
326
+ }
327
+ }
328
+ }
329
+ }
330
+ return this.adminforth.resource(this.resourceConfig.resourceId).update(ID, fieldsToUpdate[idx]);
331
+ }));
181
332
  yield Promise.all(updates);
182
333
  return { ok: true };
183
334
  })
184
335
  });
336
+ server.endpoint({
337
+ method: 'POST',
338
+ path: `/plugin/${this.pluginInstanceId}/regenerate_images`,
339
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, headers }) {
340
+ var _b;
341
+ const Id = body.recordId || [];
342
+ const prompt = body.prompt || '';
343
+ const fieldName = body.fieldName || '';
344
+ if (this.checkRateLimit(this.options.generateImages[fieldName].rateLimit, headers)) {
345
+ return { error: "Rate limit exceeded" };
346
+ }
347
+ const start = +new Date();
348
+ const STUB_MODE = false;
349
+ const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ((_b = this.resourceConfig.columns.find(c => c.primaryKey)) === null || _b === void 0 ? void 0 : _b.name, Id)]);
350
+ let attachmentFiles;
351
+ if (!this.options.attachFiles) {
352
+ attachmentFiles = [];
353
+ }
354
+ else {
355
+ attachmentFiles = yield this.options.attachFiles({ record });
356
+ }
357
+ const images = yield Promise.all((new Array(this.options.generateImages[fieldName].countToGenerate)).fill(0).map(() => __awaiter(this, void 0, void 0, function* () {
358
+ if (STUB_MODE) {
359
+ yield new Promise((resolve) => setTimeout(resolve, 2000));
360
+ return `https://picsum.photos/200/300?random=${Math.floor(Math.random() * 1000)}`;
361
+ }
362
+ let generationAdapter;
363
+ if (this.options.generateImages[fieldName].adapter) {
364
+ generationAdapter = this.options.generateImages[fieldName].adapter;
365
+ }
366
+ else {
367
+ generationAdapter = this.options.imageGenerationAdapter;
368
+ }
369
+ const resp = yield generationAdapter.generate({
370
+ prompt,
371
+ inputFiles: attachmentFiles,
372
+ n: 1,
373
+ size: this.options.generateImages[fieldName].outputSize,
374
+ });
375
+ return resp.imageURLs[0];
376
+ })));
377
+ this.totalCalls++;
378
+ this.totalDuration += (+new Date() - start) / 1000;
379
+ return { images };
380
+ })
381
+ });
382
+ server.endpoint({
383
+ method: 'POST',
384
+ path: `/plugin/${this.pluginInstanceId}/initial_image_generate`,
385
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, headers }) {
386
+ const selectedIds = body.selectedIds || [];
387
+ const STUB_MODE = false;
388
+ if (this.checkRateLimit(this.options.bulkGenerationRateLimit, headers)) {
389
+ return { error: "Rate limit exceeded" };
390
+ }
391
+ const start = +new Date();
392
+ const tasks = selectedIds.map((ID) => __awaiter(this, void 0, void 0, function* () {
393
+ var _a, _b;
394
+ const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ((_a = this.resourceConfig.columns.find(c => c.primaryKey)) === null || _a === void 0 ? void 0 : _a.name, ID)]);
395
+ let attachmentFiles;
396
+ if (!this.options.attachFiles) {
397
+ attachmentFiles = [];
398
+ }
399
+ else {
400
+ attachmentFiles = yield this.options.attachFiles({ record });
401
+ }
402
+ const fieldTasks = Object.keys(((_b = this.options) === null || _b === void 0 ? void 0 : _b.generateImages) || {}).map((key) => __awaiter(this, void 0, void 0, function* () {
403
+ const prompt = this.compileGenerationFieldTemplates(record)[key];
404
+ let images;
405
+ if (STUB_MODE) {
406
+ yield new Promise((resolve) => setTimeout(resolve, 2000));
407
+ images = `https://picsum.photos/200/300?random=${Math.floor(Math.random() * 1000)}`;
408
+ }
409
+ else {
410
+ let generationAdapter;
411
+ if (this.options.generateImages[key].adapter) {
412
+ generationAdapter = this.options.generateImages[key].adapter;
413
+ }
414
+ else {
415
+ generationAdapter = this.options.imageGenerationAdapter;
416
+ ``;
417
+ }
418
+ const resp = yield generationAdapter.generate({
419
+ prompt,
420
+ inputFiles: attachmentFiles,
421
+ n: 1,
422
+ size: this.options.generateImages[key].outputSize,
423
+ });
424
+ images = resp.imageURLs[0];
425
+ }
426
+ return { key, images };
427
+ }));
428
+ const fieldResults = yield Promise.all(fieldTasks);
429
+ const recordResult = {};
430
+ fieldResults.forEach(({ key, images }) => {
431
+ recordResult[key] = images;
432
+ });
433
+ return recordResult;
434
+ }));
435
+ const result = yield Promise.all(tasks);
436
+ this.totalCalls++;
437
+ this.totalDuration += (+new Date() - start) / 1000;
438
+ return { result };
439
+ })
440
+ });
441
+ server.endpoint({
442
+ method: 'POST',
443
+ path: `/plugin/${this.pluginInstanceId}/get_generation_prompts`,
444
+ handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, headers }) {
445
+ var _b;
446
+ const Id = body.recordId || [];
447
+ const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ((_b = this.resourceConfig.columns.find(c => c.primaryKey)) === null || _b === void 0 ? void 0 : _b.name, Id)]);
448
+ const compiledGenerationOptions = this.compileGenerationFieldTemplates(record);
449
+ return { generationOptions: compiledGenerationOptions };
450
+ })
451
+ });
452
+ server.endpoint({
453
+ method: 'GET',
454
+ path: `/plugin/${this.pluginInstanceId}/averageDuration`,
455
+ handler: () => __awaiter(this, void 0, void 0, function* () {
456
+ return {
457
+ totalCalls: this.totalCalls,
458
+ totalDuration: this.totalDuration,
459
+ averageDuration: this.totalCalls ? this.totalDuration / this.totalCalls : null,
460
+ };
461
+ })
462
+ });
185
463
  }
186
464
  }