@lobehub/lobehub 2.0.0-next.235 → 2.0.0-next.237

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.
Files changed (30) hide show
  1. package/.devcontainer/devcontainer.json +4 -2
  2. package/CHANGELOG.md +58 -0
  3. package/changelog/v1.json +10 -0
  4. package/locales/zh-CN/subscription.json +1 -1
  5. package/package.json +1 -1
  6. package/packages/model-bank/src/aiModels/anthropic.ts +0 -30
  7. package/packages/model-bank/src/aiModels/volcengine.ts +2 -1
  8. package/packages/types/src/user/onboarding.ts +3 -1
  9. package/src/app/[variants]/(main)/chat/_layout/Sidebar/Header/Nav.tsx +19 -1
  10. package/src/app/[variants]/(main)/chat/_layout/Sidebar/Header/index.tsx +1 -2
  11. package/src/app/[variants]/(main)/settings/profile/features/KlavisAuthorizationList/index.tsx +6 -24
  12. package/src/app/[variants]/(main)/settings/profile/index.tsx +17 -3
  13. package/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/index.tsx +1 -7
  14. package/src/app/[variants]/onboarding/features/FullNameStep.tsx +18 -5
  15. package/src/app/[variants]/onboarding/features/InterestsStep.tsx +19 -7
  16. package/src/app/[variants]/onboarding/features/ProSettingsStep.tsx +30 -8
  17. package/src/app/[variants]/onboarding/features/ResponseLanguageStep.tsx +18 -4
  18. package/src/app/[variants]/onboarding/features/TelemetryStep.tsx +14 -5
  19. package/src/app/[variants]/onboarding/index.tsx +2 -1
  20. package/src/server/services/search/impls/exa/index.ts +1 -1
  21. package/src/server/services/search/impls/search1api/index.ts +1 -1
  22. package/src/server/services/search/impls/tavily/index.ts +1 -1
  23. package/src/store/tool/slices/klavisStore/action.test.ts +167 -2
  24. package/src/store/tool/slices/klavisStore/action.ts +9 -8
  25. package/src/store/tool/slices/klavisStore/initialState.ts +3 -0
  26. package/src/store/user/slices/auth/action.ts +1 -0
  27. package/src/store/user/slices/onboarding/action.test.ts +342 -0
  28. package/src/store/user/slices/onboarding/action.ts +4 -9
  29. package/src/store/user/slices/onboarding/selectors.test.ts +222 -0
  30. package/src/store/user/slices/onboarding/selectors.ts +6 -1
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
 
3
+ import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
3
4
  import { Flexbox } from '@lobehub/ui';
4
5
  import { memo } from 'react';
5
6
 
@@ -40,7 +41,7 @@ const OnboardingPage = memo(() => {
40
41
  case 4: {
41
42
  return <ResponseLanguageStep onBack={goToPreviousStep} onNext={goToNextStep} />;
42
43
  }
43
- case 5: {
44
+ case MAX_ONBOARDING_STEPS: {
44
45
  return <ProSettingsStep onBack={goToPreviousStep} />;
45
46
  }
46
47
  default: {
@@ -47,7 +47,7 @@ export class ExaImpl implements SearchServiceImpl {
47
47
  };
48
48
  })()
49
49
  : {}),
50
- category: // Exa 只支持 news 类型
50
+ category: // Exa only supports news type
51
51
  params?.searchCategories?.filter((cat) => ['news'].includes(cat))?.[0],
52
52
  };
53
53
 
@@ -41,7 +41,7 @@ export class Search1APIImpl implements SearchServiceImpl {
41
41
  const { searchEngines } = params;
42
42
 
43
43
  const defaultQueryParams: Search1APIQueryParams = {
44
- crawl_results: 0, // 默认不做抓取
44
+ crawl_results: 0, // Default is no crawling
45
45
  image: false,
46
46
  max_results: 15, // Default max results
47
47
  query,
@@ -42,7 +42,7 @@ export class TavilyImpl implements SearchServiceImpl {
42
42
  params?.searchTimeRange && params.searchTimeRange !== 'anytime'
43
43
  ? params.searchTimeRange
44
44
  : undefined,
45
- topic: // Tavily 只支持 news general 两种类型
45
+ topic: // Tavily only supports news and general types
46
46
  params?.searchCategories?.filter((cat) => ['news', 'general'].includes(cat))?.[0],
47
47
  };
48
48
 
@@ -1,5 +1,5 @@
1
- import { act, renderHook } from '@testing-library/react';
2
- import { describe, expect, it, vi } from 'vitest';
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
3
 
4
4
  import { lambdaClient, toolsClient } from '@/libs/trpc/client';
5
5
 
@@ -8,6 +8,10 @@ import { KlavisServerStatus } from './types';
8
8
 
9
9
  vi.mock('zustand/traditional');
10
10
 
11
+ beforeEach(() => {
12
+ vi.clearAllMocks();
13
+ });
14
+
11
15
  vi.mock('@/libs/trpc/client', () => ({
12
16
  lambdaClient: {
13
17
  klavis: {
@@ -509,4 +513,165 @@ describe('klavisStore actions', () => {
509
513
  expect(lambdaClient.klavis.getServerInstance.query).toHaveBeenCalled();
510
514
  });
511
515
  });
516
+
517
+ describe('useFetchUserKlavisServers', () => {
518
+ it('should set isServersInit to true on success with empty data', async () => {
519
+ act(() => {
520
+ useToolStore.setState({
521
+ servers: [],
522
+ loadingServerIds: new Set(),
523
+ executingToolIds: new Set(),
524
+ isServersInit: false,
525
+ });
526
+ });
527
+
528
+ vi.mocked(lambdaClient.klavis.getKlavisPlugins.query).mockResolvedValue([]);
529
+
530
+ renderHook(() => useToolStore.getState().useFetchUserKlavisServers(true));
531
+
532
+ await waitFor(() => {
533
+ expect(useToolStore.getState().isServersInit).toBe(true);
534
+ });
535
+ });
536
+
537
+ it('should not fetch when disabled', () => {
538
+ act(() => {
539
+ useToolStore.setState({
540
+ servers: [],
541
+ loadingServerIds: new Set(),
542
+ executingToolIds: new Set(),
543
+ isServersInit: false,
544
+ });
545
+ });
546
+
547
+ vi.mocked(lambdaClient.klavis.getKlavisPlugins.query).mockClear();
548
+
549
+ renderHook(() => useToolStore.getState().useFetchUserKlavisServers(false));
550
+
551
+ expect(lambdaClient.klavis.getKlavisPlugins.query).not.toHaveBeenCalled();
552
+ expect(useToolStore.getState().isServersInit).toBe(false);
553
+ });
554
+ });
555
+
556
+ describe('server deduplication logic', () => {
557
+ it('should deduplicate servers by identifier when adding new servers', () => {
558
+ // This tests the deduplication logic used in useFetchUserKlavisServers onSuccess
559
+ act(() => {
560
+ useToolStore.setState({
561
+ servers: [
562
+ {
563
+ identifier: 'gmail',
564
+ serverName: 'Gmail',
565
+ instanceId: 'existing-inst',
566
+ serverUrl: 'https://klavis.ai/gmail',
567
+ status: KlavisServerStatus.CONNECTED,
568
+ isAuthenticated: true,
569
+ createdAt: Date.now(),
570
+ },
571
+ ],
572
+ loadingServerIds: new Set(),
573
+ executingToolIds: new Set(),
574
+ isServersInit: false,
575
+ });
576
+ });
577
+
578
+ // Simulate what onSuccess does
579
+ const incomingServers = [
580
+ {
581
+ identifier: 'gmail',
582
+ serverName: 'Gmail',
583
+ instanceId: 'new-inst',
584
+ serverUrl: 'https://klavis.ai/gmail',
585
+ status: KlavisServerStatus.CONNECTED,
586
+ isAuthenticated: true,
587
+ createdAt: Date.now(),
588
+ },
589
+ {
590
+ identifier: 'github',
591
+ serverName: 'GitHub',
592
+ instanceId: 'github-inst',
593
+ serverUrl: 'https://klavis.ai/github',
594
+ status: KlavisServerStatus.CONNECTED,
595
+ isAuthenticated: true,
596
+ createdAt: Date.now(),
597
+ },
598
+ ];
599
+
600
+ act(() => {
601
+ const existingServers = useToolStore.getState().servers;
602
+ const existingIdentifiers = new Set(existingServers.map((s) => s.identifier));
603
+ const newServers = incomingServers.filter((s) => !existingIdentifiers.has(s.identifier));
604
+
605
+ useToolStore.setState({
606
+ servers: [...existingServers, ...newServers],
607
+ isServersInit: true,
608
+ });
609
+ });
610
+
611
+ const finalServers = useToolStore.getState().servers;
612
+ expect(finalServers).toHaveLength(2);
613
+ // Existing gmail should keep its original instanceId
614
+ expect(finalServers.find((s) => s.identifier === 'gmail')?.instanceId).toBe('existing-inst');
615
+ // New github should be added
616
+ expect(finalServers.find((s) => s.identifier === 'github')?.instanceId).toBe('github-inst');
617
+ expect(useToolStore.getState().isServersInit).toBe(true);
618
+ });
619
+
620
+ it('should add all servers when none exist', () => {
621
+ act(() => {
622
+ useToolStore.setState({
623
+ servers: [],
624
+ loadingServerIds: new Set(),
625
+ executingToolIds: new Set(),
626
+ isServersInit: false,
627
+ });
628
+ });
629
+
630
+ const incomingServers = [
631
+ {
632
+ identifier: 'gmail',
633
+ serverName: 'Gmail',
634
+ instanceId: 'inst-1',
635
+ serverUrl: 'https://klavis.ai/gmail',
636
+ status: KlavisServerStatus.CONNECTED,
637
+ isAuthenticated: true,
638
+ createdAt: Date.now(),
639
+ },
640
+ ];
641
+
642
+ act(() => {
643
+ const existingServers = useToolStore.getState().servers;
644
+ const existingIdentifiers = new Set(existingServers.map((s) => s.identifier));
645
+ const newServers = incomingServers.filter((s) => !existingIdentifiers.has(s.identifier));
646
+
647
+ useToolStore.setState({
648
+ servers: [...existingServers, ...newServers],
649
+ isServersInit: true,
650
+ });
651
+ });
652
+
653
+ expect(useToolStore.getState().servers).toHaveLength(1);
654
+ expect(useToolStore.getState().isServersInit).toBe(true);
655
+ });
656
+
657
+ it('should set isServersInit even when no servers are added', () => {
658
+ act(() => {
659
+ useToolStore.setState({
660
+ servers: [],
661
+ loadingServerIds: new Set(),
662
+ executingToolIds: new Set(),
663
+ isServersInit: false,
664
+ });
665
+ });
666
+
667
+ // Simulate empty data case
668
+ act(() => {
669
+ useToolStore.setState({
670
+ isServersInit: true,
671
+ });
672
+ });
673
+
674
+ expect(useToolStore.getState().isServersInit).toBe(true);
675
+ });
676
+ });
512
677
  });
@@ -356,18 +356,19 @@ export const createKlavisStoreSlice: StateCreator<
356
356
  {
357
357
  fallbackData: [],
358
358
  onSuccess: (data) => {
359
- if (data.length > 0) {
360
- set(
361
- produce((draft: KlavisStoreState) => {
359
+ set(
360
+ produce((draft: KlavisStoreState) => {
361
+ if (data.length > 0) {
362
362
  // 使用 identifier 检查是否已存在
363
363
  const existingIdentifiers = new Set(draft.servers.map((s) => s.identifier));
364
364
  const newServers = data.filter((s) => !existingIdentifiers.has(s.identifier));
365
365
  draft.servers = [...draft.servers, ...newServers];
366
- }),
367
- false,
368
- n('useFetchUserKlavisServers'),
369
- );
370
- }
366
+ }
367
+ draft.isServersInit = true;
368
+ }),
369
+ false,
370
+ n('useFetchUserKlavisServers'),
371
+ );
371
372
  },
372
373
  revalidateOnFocus: false,
373
374
  },
@@ -9,6 +9,8 @@ import { type KlavisServer } from './types';
9
9
  export interface KlavisStoreState {
10
10
  /** 正在执行的工具调用 ID 集合 */
11
11
  executingToolIds: Set<string>;
12
+ /** 是否已完成初始化加载 */
13
+ isServersInit: boolean;
12
14
  /** 正在加载的服务器 ID 集合 */
13
15
  loadingServerIds: Set<string>;
14
16
  /** 已创建的 Klavis Server 列表 */
@@ -20,6 +22,7 @@ export interface KlavisStoreState {
20
22
  */
21
23
  export const initialKlavisStoreState: KlavisStoreState = {
22
24
  executingToolIds: new Set(),
25
+ isServersInit: false,
23
26
  loadingServerIds: new Set(),
24
27
  servers: [],
25
28
  };
@@ -41,6 +41,7 @@ const fetchAuthProvidersData = async (): Promise<AuthProvidersData> => {
41
41
  accounts
42
42
  .filter((account) => account.providerId !== 'credential')
43
43
  .map(async (account) => {
44
+ // In theory, the id_token could be decrypted from the accounts table, but I found that better-auth on GitHub does not save the id_token
44
45
  const info = await accountInfo({
45
46
  query: { accountId: account.accountId },
46
47
  });
@@ -0,0 +1,342 @@
1
+ import { CURRENT_ONBOARDING_VERSION } from '@lobechat/const';
2
+ import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
3
+ import { act, renderHook } from '@testing-library/react';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { userService } from '@/services/user';
7
+ import { useUserStore } from '@/store/user';
8
+
9
+ import { initialOnboardingState } from './initialState';
10
+
11
+ vi.mock('zustand/traditional');
12
+
13
+ vi.mock('@/services/user', () => ({
14
+ userService: {
15
+ updateOnboarding: vi.fn(),
16
+ },
17
+ }));
18
+
19
+ describe('onboarding actions', () => {
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ act(() => {
23
+ useUserStore.setState({
24
+ ...initialOnboardingState,
25
+ onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
26
+ refreshUserState: vi.fn(),
27
+ });
28
+ });
29
+ });
30
+
31
+ afterEach(() => {
32
+ vi.restoreAllMocks();
33
+ });
34
+
35
+ describe('goToNextStep', () => {
36
+ it('should increment step and set localOnboardingStep', () => {
37
+ const { result } = renderHook(() => useUserStore());
38
+
39
+ act(() => {
40
+ useUserStore.setState({
41
+ ...initialOnboardingState,
42
+ onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
43
+ });
44
+ });
45
+
46
+ act(() => {
47
+ result.current.goToNextStep();
48
+ });
49
+
50
+ expect(result.current.localOnboardingStep).toBe(2);
51
+ });
52
+
53
+ it('should not increment step when already at MAX_ONBOARDING_STEPS', () => {
54
+ const { result } = renderHook(() => useUserStore());
55
+
56
+ act(() => {
57
+ useUserStore.setState({
58
+ ...initialOnboardingState,
59
+ localOnboardingStep: MAX_ONBOARDING_STEPS,
60
+ onboarding: { currentStep: MAX_ONBOARDING_STEPS, version: CURRENT_ONBOARDING_VERSION },
61
+ });
62
+ });
63
+
64
+ act(() => {
65
+ result.current.goToNextStep();
66
+ });
67
+
68
+ // localOnboardingStep should remain at MAX_ONBOARDING_STEPS
69
+ expect(result.current.localOnboardingStep).toBe(MAX_ONBOARDING_STEPS);
70
+ });
71
+
72
+ it('should queue step update when incrementing', () => {
73
+ const { result } = renderHook(() => useUserStore());
74
+
75
+ const queueStepUpdateSpy = vi.spyOn(result.current, 'internal_queueStepUpdate');
76
+
77
+ act(() => {
78
+ useUserStore.setState({
79
+ ...initialOnboardingState,
80
+ onboarding: { currentStep: 2, version: CURRENT_ONBOARDING_VERSION },
81
+ });
82
+ });
83
+
84
+ act(() => {
85
+ result.current.goToNextStep();
86
+ });
87
+
88
+ expect(queueStepUpdateSpy).toHaveBeenCalledWith(3);
89
+ });
90
+
91
+ it('should not queue step update when at MAX_ONBOARDING_STEPS', () => {
92
+ const { result } = renderHook(() => useUserStore());
93
+
94
+ const queueStepUpdateSpy = vi.spyOn(result.current, 'internal_queueStepUpdate');
95
+
96
+ act(() => {
97
+ useUserStore.setState({
98
+ ...initialOnboardingState,
99
+ localOnboardingStep: MAX_ONBOARDING_STEPS,
100
+ onboarding: { currentStep: MAX_ONBOARDING_STEPS, version: CURRENT_ONBOARDING_VERSION },
101
+ });
102
+ });
103
+
104
+ act(() => {
105
+ result.current.goToNextStep();
106
+ });
107
+
108
+ expect(queueStepUpdateSpy).not.toHaveBeenCalled();
109
+ });
110
+ });
111
+
112
+ describe('goToPreviousStep', () => {
113
+ it('should decrement step and set localOnboardingStep', () => {
114
+ const { result } = renderHook(() => useUserStore());
115
+
116
+ act(() => {
117
+ useUserStore.setState({
118
+ ...initialOnboardingState,
119
+ localOnboardingStep: 3,
120
+ onboarding: { currentStep: 3, version: CURRENT_ONBOARDING_VERSION },
121
+ });
122
+ });
123
+
124
+ act(() => {
125
+ result.current.goToPreviousStep();
126
+ });
127
+
128
+ expect(result.current.localOnboardingStep).toBe(2);
129
+ });
130
+
131
+ it('should not decrement step when already at step 1', () => {
132
+ const { result } = renderHook(() => useUserStore());
133
+
134
+ act(() => {
135
+ useUserStore.setState({
136
+ ...initialOnboardingState,
137
+ localOnboardingStep: 1,
138
+ onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
139
+ });
140
+ });
141
+
142
+ act(() => {
143
+ result.current.goToPreviousStep();
144
+ });
145
+
146
+ // localOnboardingStep should remain at 1
147
+ expect(result.current.localOnboardingStep).toBe(1);
148
+ });
149
+
150
+ it('should queue step update when decrementing', () => {
151
+ const { result } = renderHook(() => useUserStore());
152
+
153
+ const queueStepUpdateSpy = vi.spyOn(result.current, 'internal_queueStepUpdate');
154
+
155
+ act(() => {
156
+ useUserStore.setState({
157
+ ...initialOnboardingState,
158
+ localOnboardingStep: 3,
159
+ onboarding: { currentStep: 3, version: CURRENT_ONBOARDING_VERSION },
160
+ });
161
+ });
162
+
163
+ act(() => {
164
+ result.current.goToPreviousStep();
165
+ });
166
+
167
+ expect(queueStepUpdateSpy).toHaveBeenCalledWith(2);
168
+ });
169
+
170
+ it('should not queue step update when at step 1', () => {
171
+ const { result } = renderHook(() => useUserStore());
172
+
173
+ const queueStepUpdateSpy = vi.spyOn(result.current, 'internal_queueStepUpdate');
174
+
175
+ act(() => {
176
+ useUserStore.setState({
177
+ ...initialOnboardingState,
178
+ localOnboardingStep: 1,
179
+ onboarding: { currentStep: 1, version: CURRENT_ONBOARDING_VERSION },
180
+ });
181
+ });
182
+
183
+ act(() => {
184
+ result.current.goToPreviousStep();
185
+ });
186
+
187
+ expect(queueStepUpdateSpy).not.toHaveBeenCalled();
188
+ });
189
+ });
190
+
191
+ describe('internal_queueStepUpdate', () => {
192
+ it('should add task to empty queue and start processing', () => {
193
+ const { result } = renderHook(() => useUserStore());
194
+
195
+ act(() => {
196
+ useUserStore.setState({
197
+ ...initialOnboardingState,
198
+ stepUpdateQueue: [],
199
+ });
200
+ });
201
+
202
+ const processSpy = vi.spyOn(result.current, 'internal_processStepUpdateQueue');
203
+
204
+ act(() => {
205
+ result.current.internal_queueStepUpdate(2);
206
+ });
207
+
208
+ expect(result.current.stepUpdateQueue).toContain(2);
209
+ expect(processSpy).toHaveBeenCalled();
210
+ });
211
+
212
+ it('should add pending task when one task is executing', () => {
213
+ const { result } = renderHook(() => useUserStore());
214
+
215
+ act(() => {
216
+ useUserStore.setState({
217
+ ...initialOnboardingState,
218
+ stepUpdateQueue: [2],
219
+ isProcessingStepQueue: true,
220
+ });
221
+ });
222
+
223
+ act(() => {
224
+ result.current.internal_queueStepUpdate(3);
225
+ });
226
+
227
+ expect(result.current.stepUpdateQueue).toEqual([2, 3]);
228
+ });
229
+
230
+ it('should replace pending task when queue has two tasks', () => {
231
+ const { result } = renderHook(() => useUserStore());
232
+
233
+ act(() => {
234
+ useUserStore.setState({
235
+ ...initialOnboardingState,
236
+ stepUpdateQueue: [2, 3],
237
+ isProcessingStepQueue: true,
238
+ });
239
+ });
240
+
241
+ act(() => {
242
+ result.current.internal_queueStepUpdate(4);
243
+ });
244
+
245
+ expect(result.current.stepUpdateQueue).toEqual([2, 4]);
246
+ });
247
+ });
248
+
249
+ describe('internal_processStepUpdateQueue', () => {
250
+ it('should not process when already processing', async () => {
251
+ const { result } = renderHook(() => useUserStore());
252
+
253
+ act(() => {
254
+ useUserStore.setState({
255
+ ...initialOnboardingState,
256
+ stepUpdateQueue: [2],
257
+ isProcessingStepQueue: true,
258
+ });
259
+ });
260
+
261
+ await act(async () => {
262
+ await result.current.internal_processStepUpdateQueue();
263
+ });
264
+
265
+ // userService.updateOnboarding should not be called
266
+ expect(userService.updateOnboarding).not.toHaveBeenCalled();
267
+ });
268
+
269
+ it('should not process when queue is empty', async () => {
270
+ const { result } = renderHook(() => useUserStore());
271
+
272
+ act(() => {
273
+ useUserStore.setState({
274
+ ...initialOnboardingState,
275
+ stepUpdateQueue: [],
276
+ isProcessingStepQueue: false,
277
+ });
278
+ });
279
+
280
+ await act(async () => {
281
+ await result.current.internal_processStepUpdateQueue();
282
+ });
283
+
284
+ expect(userService.updateOnboarding).not.toHaveBeenCalled();
285
+ });
286
+
287
+ it('should process queue and call userService.updateOnboarding', async () => {
288
+ const { result } = renderHook(() => useUserStore());
289
+
290
+ vi.mocked(userService.updateOnboarding).mockResolvedValue({} as any);
291
+
292
+ act(() => {
293
+ useUserStore.setState({
294
+ ...initialOnboardingState,
295
+ stepUpdateQueue: [2],
296
+ isProcessingStepQueue: false,
297
+ onboarding: { version: CURRENT_ONBOARDING_VERSION },
298
+ refreshUserState: vi.fn(),
299
+ });
300
+ });
301
+
302
+ await act(async () => {
303
+ await result.current.internal_processStepUpdateQueue();
304
+ });
305
+
306
+ expect(userService.updateOnboarding).toHaveBeenCalledWith({
307
+ currentStep: 2,
308
+ finishedAt: undefined,
309
+ version: CURRENT_ONBOARDING_VERSION,
310
+ });
311
+ });
312
+
313
+ it('should handle errors gracefully and continue processing', async () => {
314
+ const { result } = renderHook(() => useUserStore());
315
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
316
+
317
+ vi.mocked(userService.updateOnboarding).mockRejectedValueOnce(new Error('Update failed'));
318
+
319
+ act(() => {
320
+ useUserStore.setState({
321
+ ...initialOnboardingState,
322
+ stepUpdateQueue: [2],
323
+ isProcessingStepQueue: false,
324
+ onboarding: { version: CURRENT_ONBOARDING_VERSION },
325
+ refreshUserState: vi.fn(),
326
+ });
327
+ });
328
+
329
+ await act(async () => {
330
+ await result.current.internal_processStepUpdateQueue();
331
+ });
332
+
333
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
334
+ 'Failed to update onboarding step:',
335
+ expect.any(Error),
336
+ );
337
+ expect(result.current.isProcessingStepQueue).toBe(false);
338
+
339
+ consoleErrorSpy.mockRestore();
340
+ });
341
+ });
342
+ });
@@ -1,4 +1,5 @@
1
1
  import { CURRENT_ONBOARDING_VERSION, INBOX_SESSION_ID } from '@lobechat/const';
2
+ import { MAX_ONBOARDING_STEPS } from '@lobechat/types';
2
3
  import type { StateCreator } from 'zustand/vanilla';
3
4
 
4
5
  import { userService } from '@/services/user';
@@ -51,25 +52,19 @@ export const createOnboardingSlice: StateCreator<
51
52
 
52
53
  goToNextStep: () => {
53
54
  const currentStep = onboardingSelectors.currentStep(get());
54
- const nextStep = currentStep + 1;
55
+ if (currentStep === MAX_ONBOARDING_STEPS) return;
55
56
 
56
- // Optimistic update: immediately update local state
57
+ const nextStep = currentStep + 1;
57
58
  set({ localOnboardingStep: nextStep }, false, 'goToNextStep/optimistic');
58
-
59
- // Queue the server update
60
59
  get().internal_queueStepUpdate(nextStep);
61
60
  },
62
61
 
63
62
  goToPreviousStep: () => {
64
63
  const currentStep = onboardingSelectors.currentStep(get());
65
- if (currentStep <= 1) return;
64
+ if (currentStep === 1) return;
66
65
 
67
66
  const prevStep = currentStep - 1;
68
-
69
- // Optimistic update: immediately update local state
70
67
  set({ localOnboardingStep: prevStep }, false, 'goToPreviousStep/optimistic');
71
-
72
- // Queue the server update
73
68
  get().internal_queueStepUpdate(prevStep);
74
69
  },
75
70