@lobehub/lobehub 2.0.0-next.236 → 2.0.0-next.238

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 (32) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/changelog/v1.json +17 -0
  3. package/locales/en-US/oauth.json +1 -0
  4. package/locales/zh-CN/oauth.json +1 -0
  5. package/locales/zh-CN/subscription.json +1 -1
  6. package/package.json +1 -1
  7. package/packages/const/src/klavis.ts +1 -7
  8. package/packages/types/src/user/onboarding.ts +3 -1
  9. package/src/app/[variants]/(auth)/oauth/callback/success/page.tsx +44 -2
  10. package/src/app/[variants]/(main)/chat/_layout/Sidebar/Header/Nav.tsx +19 -1
  11. package/src/app/[variants]/(main)/chat/_layout/Sidebar/Header/index.tsx +1 -2
  12. package/src/app/[variants]/(main)/chat/_layout/Sidebar/Topic/List/Item/Editing.tsx +18 -9
  13. package/src/app/[variants]/(main)/settings/profile/features/KlavisAuthorizationList/index.tsx +6 -24
  14. package/src/app/[variants]/(main)/settings/profile/index.tsx +17 -3
  15. package/src/app/[variants]/onboarding/features/FullNameStep.tsx +18 -5
  16. package/src/app/[variants]/onboarding/features/InterestsStep.tsx +19 -7
  17. package/src/app/[variants]/onboarding/features/ProSettingsStep.tsx +30 -8
  18. package/src/app/[variants]/onboarding/features/ResponseLanguageStep.tsx +18 -4
  19. package/src/app/[variants]/onboarding/features/TelemetryStep.tsx +14 -5
  20. package/src/app/[variants]/onboarding/index.tsx +2 -1
  21. package/src/features/ChatInput/ActionBar/Tools/LobehubSkillServerItem.tsx +42 -2
  22. package/src/server/services/search/impls/exa/index.ts +1 -1
  23. package/src/server/services/search/impls/search1api/index.ts +1 -1
  24. package/src/server/services/search/impls/tavily/index.ts +1 -1
  25. package/src/store/tool/slices/klavisStore/action.test.ts +167 -2
  26. package/src/store/tool/slices/klavisStore/action.ts +9 -8
  27. package/src/store/tool/slices/klavisStore/initialState.ts +3 -0
  28. package/src/store/user/slices/auth/action.ts +1 -0
  29. package/src/store/user/slices/onboarding/action.test.ts +342 -0
  30. package/src/store/user/slices/onboarding/action.ts +4 -9
  31. package/src/store/user/slices/onboarding/selectors.test.ts +222 -0
  32. package/src/store/user/slices/onboarding/selectors.ts +6 -1
@@ -4,7 +4,7 @@ import { SendButton } from '@lobehub/editor/react';
4
4
  import { Button, Flexbox, Select, Text } from '@lobehub/ui';
5
5
  import { cssVar } from 'antd-style';
6
6
  import { Undo2Icon } from 'lucide-react';
7
- import { memo, useCallback, useState } from 'react';
7
+ import { memo, useCallback, useRef, useState } from 'react';
8
8
  import { useTranslation } from 'react-i18next';
9
9
 
10
10
  import { type Locales, localeOptions, normalizeLocale } from '@/locales/resources';
@@ -24,11 +24,23 @@ const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }
24
24
  const setSettings = useUserStore((s) => s.setSettings);
25
25
 
26
26
  const [value, setValue] = useState<Locales | ''>(normalizeLocale(navigator.language));
27
+ const [isNavigating, setIsNavigating] = useState(false);
28
+ const isNavigatingRef = useRef(false);
27
29
 
28
- const handleNext = () => {
30
+ const handleNext = useCallback(() => {
31
+ if (isNavigatingRef.current) return;
32
+ isNavigatingRef.current = true;
33
+ setIsNavigating(true);
29
34
  setSettings({ general: { responseLanguage: value || '' } });
30
35
  onNext();
31
- };
36
+ }, [value, setSettings, onNext]);
37
+
38
+ const handleBack = useCallback(() => {
39
+ if (isNavigatingRef.current) return;
40
+ isNavigatingRef.current = true;
41
+ setIsNavigating(true);
42
+ onBack();
43
+ }, [onBack]);
32
44
 
33
45
  const Message = useCallback(
34
46
  () => (
@@ -73,6 +85,7 @@ const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }
73
85
  value={value}
74
86
  />
75
87
  <SendButton
88
+ disabled={isNavigating}
76
89
  onClick={handleNext}
77
90
  style={{
78
91
  zoom: 1.5,
@@ -85,8 +98,9 @@ const ResponseLanguageStep = memo<ResponseLanguageStepProps>(({ onBack, onNext }
85
98
  </Text>
86
99
  <Flexbox horizontal justify={'flex-start'} style={{ marginTop: 32 }}>
87
100
  <Button
101
+ disabled={isNavigating}
88
102
  icon={Undo2Icon}
89
- onClick={onBack}
103
+ onClick={handleBack}
90
104
  style={{
91
105
  color: cssVar.colorTextDescription,
92
106
  }}
@@ -7,7 +7,7 @@ import { LoadingDots } from '@lobehub/ui/chat';
7
7
  import { Steps, Switch } from 'antd';
8
8
  import { cssVar } from 'antd-style';
9
9
  import { BrainIcon, HeartHandshakeIcon, PencilRulerIcon, ShieldCheck } from 'lucide-react';
10
- import { memo, useCallback, useState } from 'react';
10
+ import { memo, useCallback, useRef, useState } from 'react';
11
11
  import { Trans, useTranslation } from 'react-i18next';
12
12
 
13
13
  import { ProductLogo } from '@/components/Branding';
@@ -21,12 +21,20 @@ interface TelemetryStepProps {
21
21
  const TelemetryStep = memo<TelemetryStepProps>(({ onNext }) => {
22
22
  const { t } = useTranslation('onboarding');
23
23
  const [check, setCheck] = useState(true);
24
+ const [isNavigating, setIsNavigating] = useState(false);
25
+ const isNavigatingRef = useRef(false);
24
26
  const updateGeneralConfig = useUserStore((s) => s.updateGeneralConfig);
25
27
 
26
- const handleChoice = (enabled: boolean) => {
27
- updateGeneralConfig({ telemetry: enabled });
28
- onNext();
29
- };
28
+ const handleChoice = useCallback(
29
+ (enabled: boolean) => {
30
+ if (isNavigatingRef.current) return;
31
+ isNavigatingRef.current = true;
32
+ setIsNavigating(true);
33
+ updateGeneralConfig({ telemetry: enabled });
34
+ onNext();
35
+ },
36
+ [updateGeneralConfig, onNext],
37
+ );
30
38
 
31
39
  const IconAvatar = useCallback(({ icon }: { icon: IconProps['icon'] }) => {
32
40
  return (
@@ -123,6 +131,7 @@ const TelemetryStep = memo<TelemetryStepProps>(({ onNext }) => {
123
131
  </Flexbox>
124
132
  </Flexbox>
125
133
  <Button
134
+ disabled={isNavigating}
126
135
  onClick={() => handleChoice(check)}
127
136
  size={'large'}
128
137
  style={{
@@ -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: {
@@ -135,13 +135,52 @@ const LobehubSkillServerItem = memo<LobehubSkillServerItemProps>(({ provider, la
135
135
  s.togglePlugin,
136
136
  ]);
137
137
 
138
+ // Listen for OAuth success message from popup window
139
+ useEffect(() => {
140
+ const handleMessage = async (event: MessageEvent) => {
141
+ // Verify origin for security
142
+ if (event.origin !== window.location.origin) return;
143
+
144
+ if (event.data?.type === 'LOBEHUB_SKILL_AUTH_SUCCESS' && event.data?.provider === provider) {
145
+ console.log('[LobehubSkill] OAuth success message received for provider:', provider);
146
+
147
+ // Cleanup polling/window monitoring
148
+ cleanup();
149
+
150
+ // Refresh status to get the connected state
151
+ await checkStatus(provider);
152
+
153
+ // Auto-enable the plugin after successful OAuth
154
+ // Need to get the latest server state after checkStatus
155
+ const latestServer = useToolStore
156
+ .getState()
157
+ .lobehubSkillServers?.find((s) => s.identifier === provider);
158
+ if (latestServer?.status === LobehubSkillStatus.CONNECTED) {
159
+ const newPluginId = latestServer.identifier;
160
+ const isAlreadyEnabled = agentSelectors
161
+ .currentAgentPlugins(useAgentStore.getState())
162
+ .includes(newPluginId);
163
+ if (!isAlreadyEnabled) {
164
+ console.log('[LobehubSkill] Auto-enabling plugin:', newPluginId);
165
+ togglePlugin(newPluginId);
166
+ }
167
+ }
168
+ }
169
+ };
170
+
171
+ window.addEventListener('message', handleMessage);
172
+ return () => window.removeEventListener('message', handleMessage);
173
+ }, [provider, cleanup, checkStatus, togglePlugin]);
174
+
138
175
  const handleConnect = async () => {
139
176
  // 只有已连接状态才阻止重新连接
140
177
  if (server?.isConnected) return;
141
178
 
142
179
  setIsConnecting(true);
143
180
  try {
144
- const { authorizeUrl } = await getAuthorizeUrl(provider);
181
+ // Use /oauth/callback/success as redirect URI with provider param for auto-enable
182
+ const redirectUri = `${window.location.origin}/oauth/callback/success?provider=${encodeURIComponent(provider)}`;
183
+ const { authorizeUrl } = await getAuthorizeUrl(provider, { redirectUri });
145
184
  openOAuthWindow(authorizeUrl);
146
185
  } catch (error) {
147
186
  console.error('[LobehubSkill] Failed to get authorize URL:', error);
@@ -236,7 +275,8 @@ const LobehubSkillServerItem = memo<LobehubSkillServerItemProps>(({ provider, la
236
275
  onClick={async (e) => {
237
276
  e.stopPropagation();
238
277
  try {
239
- const { authorizeUrl } = await getAuthorizeUrl(provider);
278
+ const redirectUri = `${window.location.origin}/oauth/callback/success?provider=${encodeURIComponent(provider)}`;
279
+ const { authorizeUrl } = await getAuthorizeUrl(provider, { redirectUri });
240
280
  openOAuthWindow(authorizeUrl);
241
281
  } catch (error) {
242
282
  console.error('[LobehubSkill] Failed to get authorize URL:', error);
@@ -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
  });