@lobehub/lobehub 2.0.0-next.136 → 2.0.0-next.138

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/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 2.0.0-next.138](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.137...v2.0.0-next.138)
6
+
7
+ <sup>Released on **2025-11-30**</sup>
8
+
9
+ #### 🐛 Bug Fixes
10
+
11
+ - **conversation-flow**: Support optimistic update for activeBranchIndex.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's fixed
19
+
20
+ - **conversation-flow**: Support optimistic update for activeBranchIndex, closes [#10517](https://github.com/lobehub/lobe-chat/issues/10517) ([9b5b234](https://github.com/lobehub/lobe-chat/commit/9b5b234))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
30
+ ## [Version 2.0.0-next.137](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.136...v2.0.0-next.137)
31
+
32
+ <sup>Released on **2025-11-30**</sup>
33
+
34
+ #### 🐛 Bug Fixes
35
+
36
+ - **misc**: Update apiMode handling in ChatService to prioritize user preferences.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### What's fixed
44
+
45
+ - **misc**: Update apiMode handling in ChatService to prioritize user preferences, closes [#10487](https://github.com/lobehub/lobe-chat/issues/10487) ([5483d91](https://github.com/lobehub/lobe-chat/commit/5483d91))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ## [Version 2.0.0-next.136](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.135...v2.0.0-next.136)
6
56
 
7
57
  <sup>Released on **2025-11-30**</sup>
package/CLAUDE.md CHANGED
@@ -55,6 +55,44 @@ see @.cursor/rules/typescript.mdc
55
55
  - **Dev**: Translate `locales/zh-CN/namespace.json` and `locales/en-US/namespace.json` locales file only for dev preview
56
56
  - DON'T run `pnpm i18n`, let CI auto handle it
57
57
 
58
+ ## Linear Issue Management
59
+
60
+ When working with Linear issues:
61
+
62
+ 1. **Retrieve issue details** before starting work using `mcp__linear-server__get_issue`
63
+ 2. **Check for sub-issues**: If the issue has sub-issues, retrieve and review ALL sub-issues using `mcp__linear-server__list_issues` with `parentId` filter before starting work
64
+ 3. **Update issue status** when completing tasks using `mcp__linear-server__update_issue`
65
+ 4. **MUST add completion comment** using `mcp__linear-server__create_comment`
66
+
67
+ ### Completion Comment (REQUIRED)
68
+
69
+ **Every time you complete an issue, you MUST add a comment summarizing the work done.** This is critical for:
70
+
71
+ - Team visibility and knowledge sharing
72
+ - Code review context
73
+ - Future reference and debugging
74
+
75
+ ### IMPORTANT: Per-Issue Completion Rule
76
+
77
+ **When working on multiple issues (e.g., parent issue with sub-issues), you MUST update status and add comment for EACH issue IMMEDIATELY after completing it.** Do NOT wait until all issues are done to update them in batch.
78
+
79
+ **Workflow for EACH individual issue:**
80
+
81
+ 1. Complete the implementation for this specific issue
82
+ 2. Run type check: `bun run type-check`
83
+ 3. Run related tests if applicable
84
+ 4. **IMMEDIATELY** update issue status to "Done": `mcp__linear-server__update_issue`
85
+ 5. **IMMEDIATELY** add completion comment: `mcp__linear-server__create_comment`
86
+ 6. Only then move on to the next issue
87
+
88
+ **❌ Wrong approach:**
89
+
90
+ - Complete Issue A → Complete Issue B → Complete Issue C → Update all statuses → Add all comments
91
+
92
+ **✅ Correct approach:**
93
+
94
+ - Complete Issue A → Update A status → Add A comment → Complete Issue B → Update B status → Add B comment → ...
95
+
58
96
  ## Rules Index
59
97
 
60
98
  Some useful project rules are listed in @.cursor/rules/rules-index.mdc
package/changelog/v1.json CHANGED
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "children": {},
4
+ "date": "2025-11-30",
5
+ "version": "2.0.0-next.138"
6
+ },
7
+ {
8
+ "children": {
9
+ "fixes": [
10
+ "Update apiMode handling in ChatService to prioritize user preferences."
11
+ ]
12
+ },
13
+ "date": "2025-11-30",
14
+ "version": "2.0.0-next.137"
15
+ },
2
16
  {
3
17
  "children": {
4
18
  "fixes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/lobehub",
3
- "version": "2.0.0-next.136",
3
+ "version": "2.0.0-next.138",
4
4
  "description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -11,16 +11,21 @@ import type { IdNode, Message } from '../types';
11
11
  export class BranchResolver {
12
12
  /**
13
13
  * Get active branch ID from IdNode structure (used in contextTree building)
14
+ * Returns undefined for optimistic updates when the branch hasn't been created yet
14
15
  */
15
- getActiveBranchId(message: Message, idNode: IdNode): string {
16
+ getActiveBranchId(message: Message, idNode: IdNode): string | undefined {
16
17
  // Priority 1: Try to get from metadata.activeBranchIndex (index-based)
17
18
  const activeBranchIndex = (message.metadata as any)?.activeBranchIndex;
18
- if (
19
- typeof activeBranchIndex === 'number' &&
20
- activeBranchIndex >= 0 &&
21
- activeBranchIndex < idNode.children.length
22
- ) {
23
- return idNode.children[activeBranchIndex].id;
19
+ if (typeof activeBranchIndex === 'number' && activeBranchIndex >= 0) {
20
+ // If index is within bounds, return the branch at that index
21
+ if (activeBranchIndex < idNode.children.length) {
22
+ return idNode.children[activeBranchIndex].id;
23
+ }
24
+ // Optimistic update: index === children.length means branch is being created
25
+ if (activeBranchIndex === idNode.children.length) {
26
+ return undefined;
27
+ }
28
+ // Invalid index (> children.length), ignore and continue to other strategies
24
29
  }
25
30
 
26
31
  // Priority 2: Infer from which branch has children
@@ -36,20 +41,25 @@ export class BranchResolver {
36
41
 
37
42
  /**
38
43
  * Get active branch ID from flat list (used in flatList building)
44
+ * Returns undefined for optimistic updates when the branch hasn't been created yet
39
45
  */
40
46
  getActiveBranchIdFromMetadata(
41
47
  message: Message,
42
48
  childIds: string[],
43
49
  childrenMap: Map<string | null, string[]>,
44
- ): string {
50
+ ): string | undefined {
45
51
  // Priority 1: Try to get from metadata.activeBranchIndex (index-based)
46
52
  const activeBranchIndex = (message.metadata as any)?.activeBranchIndex;
47
- if (
48
- typeof activeBranchIndex === 'number' &&
49
- activeBranchIndex >= 0 &&
50
- activeBranchIndex < childIds.length
51
- ) {
52
- return childIds[activeBranchIndex];
53
+ if (typeof activeBranchIndex === 'number' && activeBranchIndex >= 0) {
54
+ // If index is within bounds, return the branch at that index
55
+ if (activeBranchIndex < childIds.length) {
56
+ return childIds[activeBranchIndex];
57
+ }
58
+ // Optimistic update: index === childIds.length means branch is being created
59
+ if (activeBranchIndex === childIds.length) {
60
+ return undefined;
61
+ }
62
+ // Invalid index (> childIds.length), ignore and continue to other strategies
53
63
  }
54
64
 
55
65
  // Priority 2: Infer from which child has descendants
@@ -185,7 +185,12 @@ export class ContextTreeBuilder {
185
185
  */
186
186
  private createBranchNode(message: Message, idNode: IdNode): BranchNode {
187
187
  const activeBranchId = this.branchResolver.getActiveBranchId(message, idNode);
188
- const activeBranchIndex = idNode.children.findIndex((child) => child.id === activeBranchId);
188
+
189
+ // For optimistic update (activeBranchId is undefined), use children.length as the index
190
+ // This indicates the branch is being created but doesn't exist yet
191
+ const activeBranchIndex = activeBranchId
192
+ ? idNode.children.findIndex((child) => child.id === activeBranchId)
193
+ : idNode.children.length;
189
194
 
190
195
  // Each branch is a tree starting from that child
191
196
  const branches = idNode.children.map((child) => {
@@ -165,6 +165,15 @@ export class FlatListBuilder {
165
165
  childMessages,
166
166
  this.childrenMap,
167
167
  );
168
+
169
+ // Optimistic update: activeBranchId is undefined when branch is being created
170
+ // In this case, just add user message without branch info and continue
171
+ if (!activeBranchId) {
172
+ flatList.push(message);
173
+ processedIds.add(message.id);
174
+ continue;
175
+ }
176
+
168
177
  const activeBranchIndex = childMessages.indexOf(activeBranchId);
169
178
  const userWithBranches = this.createUserMessageWithBranches(
170
179
  message,
@@ -248,6 +257,12 @@ export class FlatListBuilder {
248
257
  flatList.push(message);
249
258
  processedIds.add(message.id);
250
259
 
260
+ // Optimistic update: activeBranchId is undefined when branch is being created
261
+ // In this case, just add assistant message and continue (no active branch yet)
262
+ if (!activeBranchId) {
263
+ continue;
264
+ }
265
+
251
266
  // Continue with active branch and process its message
252
267
  const activeBranchMsg = this.messageMap.get(activeBranchId);
253
268
  if (activeBranchMsg) {
@@ -71,13 +71,13 @@ describe('BranchResolver', () => {
71
71
  expect(resolver.getActiveBranchId(message, idNode)).toBe('msg-2');
72
72
  });
73
73
 
74
- it('should ignore invalid activeBranchIndex', () => {
74
+ it('should return undefined for optimistic update (activeBranchIndex === children.length)', () => {
75
75
  const message: Message = {
76
76
  content: 'test',
77
77
  createdAt: 0,
78
78
  id: 'msg-1',
79
79
  meta: {},
80
- metadata: { activeBranchIndex: 5 }, // out of bounds
80
+ metadata: { activeBranchIndex: 2 }, // index = children.length (optimistic update)
81
81
  role: 'user',
82
82
  updatedAt: 0,
83
83
  };
@@ -90,7 +90,31 @@ describe('BranchResolver', () => {
90
90
  id: 'msg-1',
91
91
  };
92
92
 
93
- // Should default to first branch
93
+ // When activeBranchIndex === children.length, it's an optimistic update
94
+ // The branch hasn't been created yet, so return undefined
95
+ expect(resolver.getActiveBranchId(message, idNode)).toBeUndefined();
96
+ });
97
+
98
+ it('should ignore activeBranchIndex when it exceeds optimistic update range', () => {
99
+ const message: Message = {
100
+ content: 'test',
101
+ createdAt: 0,
102
+ id: 'msg-1',
103
+ meta: {},
104
+ metadata: { activeBranchIndex: 5 }, // > children.length (invalid)
105
+ role: 'user',
106
+ updatedAt: 0,
107
+ };
108
+
109
+ const idNode: IdNode = {
110
+ children: [
111
+ { children: [], id: 'msg-2' },
112
+ { children: [], id: 'msg-3' },
113
+ ],
114
+ id: 'msg-1',
115
+ };
116
+
117
+ // activeBranchIndex > children.length should be ignored, fallback to default
94
118
  expect(resolver.getActiveBranchId(message, idNode)).toBe('msg-2');
95
119
  });
96
120
  });
@@ -147,5 +171,44 @@ describe('BranchResolver', () => {
147
171
 
148
172
  expect(resolver.getActiveBranchIdFromMetadata(message, childIds, childrenMap)).toBe('msg-2');
149
173
  });
174
+
175
+ it('should return undefined for optimistic update (activeBranchIndex === childIds.length)', () => {
176
+ const message: Message = {
177
+ content: 'test',
178
+ createdAt: 0,
179
+ id: 'msg-1',
180
+ meta: {},
181
+ metadata: { activeBranchIndex: 2 }, // index = childIds.length (optimistic update)
182
+ role: 'user',
183
+ updatedAt: 0,
184
+ };
185
+
186
+ const childIds = ['msg-2', 'msg-3'];
187
+ const childrenMap = new Map<string | null, string[]>();
188
+
189
+ // When activeBranchIndex === childIds.length, it's an optimistic update
190
+ // The branch hasn't been created yet, so return undefined
191
+ expect(
192
+ resolver.getActiveBranchIdFromMetadata(message, childIds, childrenMap),
193
+ ).toBeUndefined();
194
+ });
195
+
196
+ it('should ignore activeBranchIndex when it exceeds optimistic update range', () => {
197
+ const message: Message = {
198
+ content: 'test',
199
+ createdAt: 0,
200
+ id: 'msg-1',
201
+ meta: {},
202
+ metadata: { activeBranchIndex: 5 }, // > childIds.length (invalid)
203
+ role: 'user',
204
+ updatedAt: 0,
205
+ };
206
+
207
+ const childIds = ['msg-2', 'msg-3'];
208
+ const childrenMap = new Map<string | null, string[]>();
209
+
210
+ // activeBranchIndex > childIds.length should be ignored, fallback to default
211
+ expect(resolver.getActiveBranchIdFromMetadata(message, childIds, childrenMap)).toBe('msg-2');
212
+ });
150
213
  });
151
214
  });
@@ -311,6 +311,70 @@ describe('ContextTreeBuilder', () => {
311
311
  expect(result).toHaveLength(0);
312
312
  });
313
313
 
314
+ it('should set activeBranchIndex to children.length for optimistic update', () => {
315
+ const messageMap = new Map<string, Message>([
316
+ [
317
+ 'msg-1',
318
+ {
319
+ content: 'Hello',
320
+ createdAt: 0,
321
+ id: 'msg-1',
322
+ meta: {},
323
+ // activeBranchIndex = 2 means optimistic update (pointing to not-yet-created branch)
324
+ metadata: { activeBranchIndex: 2 },
325
+ role: 'user',
326
+ updatedAt: 0,
327
+ },
328
+ ],
329
+ [
330
+ 'msg-2',
331
+ {
332
+ content: 'Response 1',
333
+ createdAt: 0,
334
+ id: 'msg-2',
335
+ meta: {},
336
+ role: 'assistant',
337
+ updatedAt: 0,
338
+ },
339
+ ],
340
+ [
341
+ 'msg-3',
342
+ {
343
+ content: 'Response 2',
344
+ createdAt: 0,
345
+ id: 'msg-3',
346
+ meta: {},
347
+ role: 'assistant',
348
+ updatedAt: 0,
349
+ },
350
+ ],
351
+ ]);
352
+
353
+ const builder = createBuilder(messageMap);
354
+ const idNodes: IdNode[] = [
355
+ {
356
+ children: [
357
+ { children: [], id: 'msg-2' },
358
+ { children: [], id: 'msg-3' },
359
+ ],
360
+ id: 'msg-1',
361
+ },
362
+ ];
363
+
364
+ const result = builder.transformAll(idNodes);
365
+
366
+ expect(result).toHaveLength(2);
367
+ expect(result[0]).toEqual({ id: 'msg-1', type: 'message' });
368
+ // When activeBranchIndex === children.length (optimistic update),
369
+ // BranchResolver returns undefined, and ContextTreeBuilder uses children.length as index
370
+ expect(result[1]).toMatchObject({
371
+ activeBranchIndex: 2, // children.length = 2
372
+ branches: [[{ id: 'msg-2', type: 'message' }], [{ id: 'msg-3', type: 'message' }]],
373
+ parentMessageId: 'msg-1',
374
+ type: 'branch',
375
+ });
376
+ });
377
+
314
378
  it('should continue with active column children in compare mode', () => {
315
379
  const messageMap = new Map<string, Message>([
316
380
  [
@@ -507,5 +507,52 @@ describe('FlatListBuilder', () => {
507
507
  expect(result[1].role).toBe('assistantGroup');
508
508
  expect(result[2].id).toBe('msg-3');
509
509
  });
510
+
511
+ it('should handle optimistic update for user message with branches', () => {
512
+ // Scenario: User has sent a new message, activeBranchIndex points to a branch
513
+ // that is being created but doesn't exist yet (optimistic update)
514
+ const messages: Message[] = [
515
+ {
516
+ content: 'User',
517
+ createdAt: 0,
518
+ id: 'msg-1',
519
+ meta: {},
520
+ // activeBranchIndex = 2 means pointing to a not-yet-created branch (optimistic update)
521
+ // when there are only 2 existing children (msg-2, msg-3)
522
+ metadata: { activeBranchIndex: 2 },
523
+ role: 'user',
524
+ updatedAt: 0,
525
+ },
526
+ {
527
+ content: 'Branch 1',
528
+ createdAt: 0,
529
+ id: 'msg-2',
530
+ meta: {},
531
+ parentId: 'msg-1',
532
+ role: 'assistant',
533
+ updatedAt: 0,
534
+ },
535
+ {
536
+ content: 'Branch 2',
537
+ createdAt: 0,
538
+ id: 'msg-3',
539
+ meta: {},
540
+ parentId: 'msg-1',
541
+ role: 'assistant',
542
+ updatedAt: 0,
543
+ },
544
+ ];
545
+
546
+ const builder = createBuilder(messages);
547
+ const result = builder.flatten(messages);
548
+
549
+ // When activeBranchIndex === children.length (optimistic update),
550
+ // BranchResolver returns undefined, and FlatListBuilder just adds the user message
551
+ // without branch info and doesn't continue to any branch
552
+ expect(result).toHaveLength(1);
553
+ expect(result[0].id).toBe('msg-1');
554
+ // User message should not have branch info since we're in optimistic update mode
555
+ expect((result[0] as any).siblingCount).toBeUndefined();
556
+ });
510
557
  });
511
558
  });
@@ -20,9 +20,12 @@ import { getTestDB } from './_util';
20
20
  const serverDB: LobeChatDatabase = await getTestDB();
21
21
 
22
22
  const userId = 'agent-model-test-user-id';
23
+ const userId2 = 'agent-model-test-user-id-2';
23
24
  const agentModel = new AgentModel(serverDB, userId);
25
+ const agentModel2 = new AgentModel(serverDB, userId2);
24
26
 
25
27
  const knowledgeBase = { id: 'kb1', userId, name: 'knowledgeBase' };
28
+ const knowledgeBase2 = { id: 'kb2', userId: userId2, name: 'knowledgeBase2' };
26
29
  const fileList = [
27
30
  {
28
31
  id: '1',
@@ -42,11 +45,22 @@ const fileList = [
42
45
  },
43
46
  ];
44
47
 
48
+ const fileList2 = [
49
+ {
50
+ id: '3',
51
+ name: 'other.pdf',
52
+ url: 'https://a.com/other.pdf',
53
+ size: 1000,
54
+ fileType: 'application/pdf',
55
+ userId: userId2,
56
+ },
57
+ ];
58
+
45
59
  beforeEach(async () => {
46
60
  await serverDB.delete(users);
47
- await serverDB.insert(users).values([{ id: userId }]);
48
- await serverDB.insert(knowledgeBases).values(knowledgeBase);
49
- await serverDB.insert(files).values(fileList);
61
+ await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]);
62
+ await serverDB.insert(knowledgeBases).values([knowledgeBase, knowledgeBase2]);
63
+ await serverDB.insert(files).values([...fileList, ...fileList2]);
50
64
  });
51
65
 
52
66
  afterEach(async () => {
@@ -226,6 +240,27 @@ describe('AgentModel', () => {
226
240
 
227
241
  expect(result).toBeUndefined();
228
242
  });
243
+
244
+ it('should not delete another user agent knowledge base association', async () => {
245
+ const agent = await serverDB
246
+ .insert(agents)
247
+ .values({ userId })
248
+ .returning()
249
+ .then((res) => res[0]);
250
+ await serverDB
251
+ .insert(agentsKnowledgeBases)
252
+ .values({ agentId: agent.id, knowledgeBaseId: knowledgeBase.id, userId });
253
+
254
+ // Try to delete with another user's model
255
+ await agentModel2.deleteAgentKnowledgeBase(agent.id, knowledgeBase.id);
256
+
257
+ const result = await serverDB.query.agentsKnowledgeBases.findFirst({
258
+ where: eq(agentsKnowledgeBases.agentId, agent.id),
259
+ });
260
+
261
+ // Should still exist
262
+ expect(result).toBeDefined();
263
+ });
229
264
  });
230
265
 
231
266
  describe('toggleKnowledgeBase', () => {
@@ -248,6 +283,28 @@ describe('AgentModel', () => {
248
283
 
249
284
  expect(result?.enabled).toBe(false);
250
285
  });
286
+
287
+ it('should not toggle another user agent knowledge base association', async () => {
288
+ const agent = await serverDB
289
+ .insert(agents)
290
+ .values({ userId })
291
+ .returning()
292
+ .then((res) => res[0]);
293
+
294
+ await serverDB
295
+ .insert(agentsKnowledgeBases)
296
+ .values({ agentId: agent.id, knowledgeBaseId: knowledgeBase.id, userId, enabled: true });
297
+
298
+ // Try to toggle with another user's model
299
+ await agentModel2.toggleKnowledgeBase(agent.id, knowledgeBase.id, false);
300
+
301
+ const result = await serverDB.query.agentsKnowledgeBases.findFirst({
302
+ where: eq(agentsKnowledgeBases.agentId, agent.id),
303
+ });
304
+
305
+ // Should still be enabled
306
+ expect(result?.enabled).toBe(true);
307
+ });
251
308
  });
252
309
 
253
310
  describe('createAgentFiles', () => {
@@ -363,6 +420,26 @@ describe('AgentModel', () => {
363
420
 
364
421
  expect(result).toBeUndefined();
365
422
  });
423
+
424
+ it('should not delete another user agent file association', async () => {
425
+ const agent = await serverDB
426
+ .insert(agents)
427
+ .values({ userId })
428
+ .returning()
429
+ .then((res) => res[0]);
430
+
431
+ await serverDB.insert(agentsFiles).values({ agentId: agent.id, fileId: '1', userId });
432
+
433
+ // Try to delete with another user's model
434
+ await agentModel2.deleteAgentFile(agent.id, '1');
435
+
436
+ const result = await serverDB.query.agentsFiles.findFirst({
437
+ where: eq(agentsFiles.agentId, agent.id),
438
+ });
439
+
440
+ // Should still exist
441
+ expect(result).toBeDefined();
442
+ });
366
443
  });
367
444
 
368
445
  describe('toggleFile', () => {
@@ -385,5 +462,27 @@ describe('AgentModel', () => {
385
462
 
386
463
  expect(result?.enabled).toBe(false);
387
464
  });
465
+
466
+ it('should not toggle another user agent file association', async () => {
467
+ const agent = await serverDB
468
+ .insert(agents)
469
+ .values({ userId })
470
+ .returning()
471
+ .then((res) => res[0]);
472
+
473
+ await serverDB
474
+ .insert(agentsFiles)
475
+ .values({ agentId: agent.id, fileId: '1', userId, enabled: true });
476
+
477
+ // Try to toggle with another user's model
478
+ await agentModel2.toggleFile(agent.id, '1', false);
479
+
480
+ const result = await serverDB.query.agentsFiles.findFirst({
481
+ where: eq(agentsFiles.agentId, agent.id),
482
+ });
483
+
484
+ // Should still be enabled
485
+ expect(result?.enabled).toBe(true);
486
+ });
388
487
  });
389
488
  });