@lobehub/lobehub 2.0.0-next.160 → 2.0.0-next.162

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 (96) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +42 -0
  3. package/changelog/v1.json +14 -0
  4. package/e2e/src/steps/hooks.ts +1 -0
  5. package/locales/ar/authError.json +40 -0
  6. package/locales/ar/setting.json +25 -0
  7. package/locales/bg-BG/authError.json +40 -0
  8. package/locales/bg-BG/setting.json +25 -0
  9. package/locales/de-DE/authError.json +40 -0
  10. package/locales/de-DE/setting.json +25 -0
  11. package/locales/en-US/authError.json +40 -0
  12. package/locales/en-US/setting.json +25 -0
  13. package/locales/es-ES/authError.json +40 -0
  14. package/locales/es-ES/setting.json +25 -0
  15. package/locales/fa-IR/authError.json +40 -0
  16. package/locales/fa-IR/setting.json +25 -0
  17. package/locales/fr-FR/authError.json +40 -0
  18. package/locales/fr-FR/setting.json +25 -0
  19. package/locales/it-IT/authError.json +40 -0
  20. package/locales/it-IT/setting.json +25 -0
  21. package/locales/ja-JP/authError.json +40 -0
  22. package/locales/ja-JP/setting.json +25 -0
  23. package/locales/ko-KR/authError.json +40 -0
  24. package/locales/ko-KR/setting.json +25 -0
  25. package/locales/nl-NL/authError.json +40 -0
  26. package/locales/nl-NL/setting.json +25 -0
  27. package/locales/pl-PL/authError.json +40 -0
  28. package/locales/pl-PL/setting.json +25 -0
  29. package/locales/pt-BR/authError.json +40 -0
  30. package/locales/pt-BR/setting.json +25 -0
  31. package/locales/ru-RU/authError.json +40 -0
  32. package/locales/ru-RU/setting.json +25 -0
  33. package/locales/tr-TR/authError.json +40 -0
  34. package/locales/tr-TR/setting.json +25 -0
  35. package/locales/vi-VN/authError.json +40 -0
  36. package/locales/vi-VN/setting.json +25 -0
  37. package/locales/zh-CN/authError.json +40 -0
  38. package/locales/zh-CN/setting.json +25 -0
  39. package/locales/zh-TW/authError.json +40 -0
  40. package/locales/zh-TW/setting.json +25 -0
  41. package/next.config.ts +13 -1
  42. package/package.json +3 -1
  43. package/packages/const/src/index.ts +1 -0
  44. package/packages/const/src/klavis.ts +163 -0
  45. package/packages/database/migrations/meta/_journal.json +1 -1
  46. package/packages/database/src/core/migrations.json +1 -1
  47. package/packages/database/src/models/plugin.ts +1 -1
  48. package/packages/types/src/message/common/tools.ts +9 -0
  49. package/packages/types/src/serverConfig.ts +1 -0
  50. package/packages/types/src/tool/plugin.ts +10 -0
  51. package/src/app/[variants]/(auth)/auth-error/page.tsx +59 -0
  52. package/src/auth.ts +13 -48
  53. package/src/config/klavis.ts +41 -0
  54. package/src/envs/redis.ts +1 -1
  55. package/src/features/ChatInput/ActionBar/Tools/KlavisServerItem.tsx +351 -0
  56. package/src/features/ChatInput/ActionBar/Tools/index.tsx +56 -4
  57. package/src/features/ChatInput/ActionBar/Tools/useControls.tsx +174 -6
  58. package/src/features/ChatInput/ActionBar/components/ActionDropdown.tsx +3 -1
  59. package/src/helpers/toolEngineering/index.test.ts +3 -0
  60. package/src/helpers/toolEngineering/index.ts +13 -2
  61. package/src/libs/better-auth/utils/config.ts +91 -0
  62. package/src/libs/klavis/index.ts +36 -0
  63. package/src/libs/redis/manager.ts +5 -1
  64. package/src/libs/redis/redis.test.ts +1 -1
  65. package/src/libs/redis/upstash.test.ts +9 -5
  66. package/src/libs/redis/upstash.ts +44 -20
  67. package/src/locales/default/authError.ts +40 -0
  68. package/src/locales/default/index.ts +2 -0
  69. package/src/locales/default/setting.ts +25 -0
  70. package/src/proxy.ts +1 -0
  71. package/src/server/globalConfig/index.ts +2 -0
  72. package/src/server/routers/lambda/index.ts +2 -0
  73. package/src/server/routers/lambda/klavis.ts +249 -0
  74. package/src/server/routers/tools/index.ts +2 -0
  75. package/src/server/routers/tools/klavis.ts +80 -0
  76. package/src/server/services/mcp/index.ts +61 -15
  77. package/src/services/import/index.test.ts +658 -0
  78. package/src/services/mcp.test.ts +1 -1
  79. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +2 -3
  80. package/src/store/chat/slices/plugin/action.test.ts +0 -1
  81. package/src/store/chat/slices/plugin/actions/internals.ts +22 -2
  82. package/src/store/chat/slices/plugin/actions/pluginTypes.ts +108 -0
  83. package/src/store/serverConfig/index.ts +1 -1
  84. package/src/store/serverConfig/selectors.ts +1 -0
  85. package/src/store/tool/initialState.ts +4 -1
  86. package/src/store/tool/selectors/index.ts +1 -0
  87. package/src/store/tool/slices/builtin/selectors.ts +25 -3
  88. package/src/store/tool/slices/klavisStore/action.test.ts +512 -0
  89. package/src/store/tool/slices/klavisStore/action.ts +375 -0
  90. package/src/store/tool/slices/klavisStore/index.ts +4 -0
  91. package/src/store/tool/slices/klavisStore/initialState.ts +25 -0
  92. package/src/store/tool/slices/klavisStore/selectors.test.ts +371 -0
  93. package/src/store/tool/slices/klavisStore/selectors.ts +123 -0
  94. package/src/store/tool/slices/klavisStore/types.ts +100 -0
  95. package/src/store/tool/slices/plugin/selectors.ts +16 -13
  96. package/src/store/tool/store.ts +4 -1
@@ -0,0 +1,59 @@
1
+ 'use client';
2
+
3
+ import { SiDiscord } from '@icons-pack/react-simple-icons';
4
+ import { Button, Icon } from '@lobehub/ui';
5
+ import { Result, Tag, Typography } from 'antd';
6
+ import { ShieldAlert } from 'lucide-react';
7
+ import Link from 'next/link';
8
+ import { parseAsString, useQueryState } from 'nuqs';
9
+ import { memo } from 'react';
10
+ import { useTranslation } from 'react-i18next';
11
+ import { Flexbox } from 'react-layout-kit';
12
+
13
+ const DISCORD_URL = 'https://discord.gg/AYFPHvv2jT';
14
+
15
+ const normalizeErrorCode = (code?: string | null) =>
16
+ (code || 'UNKNOWN').trim().toUpperCase().replaceAll('-', '_');
17
+
18
+ const AuthErrorPage = memo(() => {
19
+ const { t } = useTranslation('authError');
20
+ const [error] = useQueryState('error', parseAsString);
21
+
22
+ const code = normalizeErrorCode(error);
23
+ const description = t(`codes.${code}`, { defaultValue: t('codes.UNKNOWN') });
24
+
25
+ return (
26
+ <Result
27
+ extra={
28
+ <Flexbox align="center" gap={16}>
29
+ <Flexbox gap={12} horizontal justify="center" wrap="wrap">
30
+ <Link href="/signin">
31
+ <Button type="primary">{t('actions.retry')}</Button>
32
+ </Link>
33
+ <Link href="/">
34
+ <Button>{t('actions.home')}</Button>
35
+ </Link>
36
+ </Flexbox>
37
+ <Link href={DISCORD_URL} rel="noopener noreferrer" target="_blank">
38
+ <Button icon={<Icon icon={SiDiscord} />} type="text">
39
+ {t('actions.discord')}
40
+ </Button>
41
+ </Link>
42
+ </Flexbox>
43
+ }
44
+ icon={<Icon icon={ShieldAlert} />}
45
+ status="error"
46
+ subTitle={
47
+ <Flexbox align="center" gap={8}>
48
+ <Tag color="red">{error || 'UNKNOWN'}</Tag>
49
+ <Typography.Text type="secondary">{description}</Typography.Text>
50
+ </Flexbox>
51
+ }
52
+ title={t('title')}
53
+ />
54
+ );
55
+ });
56
+
57
+ AuthErrorPage.displayName = 'AuthErrorPage';
58
+
59
+ export default AuthErrorPage;
package/src/auth.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
2
2
  import { createNanoId, idGenerator, serverDB } from '@lobechat/database';
3
- import { betterAuth } from 'better-auth/minimal';
4
3
  import { emailHarmony } from 'better-auth-harmony';
5
4
  import { drizzleAdapter } from 'better-auth/adapters/drizzle';
5
+ import { betterAuth } from 'better-auth/minimal';
6
6
  import { admin, genericOAuth, magicLink } from 'better-auth/plugins';
7
7
 
8
8
  import { authEnv } from '@/envs/auth';
@@ -12,6 +12,7 @@ import {
12
12
  getVerificationEmailTemplate,
13
13
  } from '@/libs/better-auth/email-templates';
14
14
  import { initBetterAuthSSOProviders } from '@/libs/better-auth/sso';
15
+ import { createSecondaryStorage, getTrustedOrigins } from '@/libs/better-auth/utils/config';
15
16
  import { parseSSOProviders } from '@/libs/better-auth/utils/server';
16
17
  import { EmailService } from '@/server/services/email';
17
18
  import { UserService } from '@/server/services/user';
@@ -21,55 +22,10 @@ import { UserService } from '@/server/services/user';
21
22
  const VERIFICATION_LINK_EXPIRES_IN = 3600;
22
23
  const MAGIC_LINK_EXPIRES_IN = 900;
23
24
  const enableMagicLink = authEnv.NEXT_PUBLIC_ENABLE_MAGIC_LINK;
24
- const APPLE_TRUSTED_ORIGIN = 'https://appleid.apple.com';
25
25
  const enabledSSOProviders = parseSSOProviders(authEnv.AUTH_SSO_PROVIDERS);
26
26
 
27
27
  const { socialProviders, genericOAuthProviders } = initBetterAuthSSOProviders();
28
28
 
29
- /**
30
- * Normalize a URL-like string to an origin with https fallback.
31
- */
32
- const normalizeOrigin = (url?: string) => {
33
- if (!url) return undefined;
34
-
35
- try {
36
- const normalizedUrl = url.startsWith('http') ? url : `https://${url}`;
37
-
38
- return new URL(normalizedUrl).origin;
39
- } catch {
40
- return undefined;
41
- }
42
- };
43
-
44
- /**
45
- * Build trusted origins with env override and Vercel-aware defaults.
46
- */
47
- const getTrustedOrigins = () => {
48
- if (authEnv.AUTH_TRUSTED_ORIGINS) {
49
- const originsFromEnv = authEnv.AUTH_TRUSTED_ORIGINS.split(',')
50
- .map((item) => normalizeOrigin(item.trim()))
51
- .filter(Boolean) as string[];
52
-
53
- if (originsFromEnv.length > 0) return Array.from(new Set(originsFromEnv));
54
- }
55
-
56
- const defaults = [
57
- authEnv.NEXT_PUBLIC_AUTH_URL,
58
- normalizeOrigin(process.env.APP_URL),
59
- normalizeOrigin(process.env.VERCEL_BRANCH_URL),
60
- normalizeOrigin(process.env.VERCEL_URL),
61
- ].filter(Boolean) as string[];
62
-
63
- const baseTrustedOrigins = defaults.length > 0 ? Array.from(new Set(defaults)) : undefined;
64
-
65
- if (!enabledSSOProviders.includes('apple')) return baseTrustedOrigins;
66
-
67
- const mergedOrigins = new Set(baseTrustedOrigins || []);
68
- mergedOrigins.add(APPLE_TRUSTED_ORIGIN);
69
-
70
- return Array.from(mergedOrigins);
71
- };
72
-
73
29
  export const auth = betterAuth({
74
30
  account: {
75
31
  accountLinking: {
@@ -82,7 +38,7 @@ export const auth = betterAuth({
82
38
  // Use renamed env vars (fallback to next-auth vars is handled in src/envs/auth.ts)
83
39
  baseURL: authEnv.NEXT_PUBLIC_AUTH_URL,
84
40
  secret: authEnv.AUTH_SECRET,
85
- trustedOrigins: getTrustedOrigins(),
41
+ trustedOrigins: getTrustedOrigins(enabledSSOProviders),
86
42
 
87
43
  emailAndPassword: {
88
44
  autoSignIn: true,
@@ -118,10 +74,19 @@ export const auth = betterAuth({
118
74
  });
119
75
  },
120
76
  },
121
-
77
+ onAPIError: {
78
+ errorURL: '/auth-error',
79
+ },
80
+ session: {
81
+ cookieCache: {
82
+ enabled: true,
83
+ maxAge: 10 * 60, // Cache duration in seconds
84
+ },
85
+ },
122
86
  database: drizzleAdapter(serverDB, {
123
87
  provider: 'pg',
124
88
  }),
89
+ secondaryStorage: createSecondaryStorage(),
125
90
  /**
126
91
  * Database joins is useful when Better-Auth needs to fetch related data from multiple tables in a single query.
127
92
  * Endpoints like /get-session, /get-full-organization and many others benefit greatly from this feature,
@@ -0,0 +1,41 @@
1
+ import { createEnv } from '@t3-oss/env-nextjs';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Klavis Service Configuration
6
+ *
7
+ * Architecture:
8
+ * - Server-side: KLAVIS_API_KEY is stored and used only on the server
9
+ * - Client-side: Klavis enabled status is provided via serverConfig store (enableKlavis)
10
+ * - Client calls server APIs which use the API key
11
+ *
12
+ * Security:
13
+ * - API key is NEVER exposed to the client
14
+ * - Client gets enabled status from server config
15
+ */
16
+ export const getKlavisConfig = () => {
17
+ return createEnv({
18
+ client: {},
19
+ runtimeEnv: {
20
+ // Server-side API key (never exposed to client)
21
+ KLAVIS_API_KEY: process.env.KLAVIS_API_KEY,
22
+ },
23
+ server: {
24
+ KLAVIS_API_KEY: z.string().optional(),
25
+ },
26
+ });
27
+ };
28
+
29
+ export const klavisEnv = getKlavisConfig();
30
+
31
+ /**
32
+ * Get Klavis API Key (server-side only)
33
+ * IMPORTANT: This should only be called from server-side code
34
+ */
35
+ export const getServerKlavisApiKey = (): string | undefined => {
36
+ if (typeof window !== 'undefined') {
37
+ console.error('[Klavis] Attempted to access API key from client-side!');
38
+ return undefined;
39
+ }
40
+ return klavisEnv.KLAVIS_API_KEY;
41
+ };
package/src/envs/redis.ts CHANGED
@@ -14,7 +14,7 @@ const parseNumber = (value?: string) => {
14
14
 
15
15
  const parseRedisTls = (value?: string) => {
16
16
  if (!value) {
17
- return false
17
+ return false;
18
18
  }
19
19
 
20
20
  const normalized = value.trim().toLowerCase();
@@ -0,0 +1,351 @@
1
+ import { Icon } from '@lobehub/ui';
2
+ import { Checkbox } from 'antd';
3
+ import { Loader2, SquareArrowOutUpRight, Unplug } from 'lucide-react';
4
+ import { memo, useCallback, useEffect, useRef, useState } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { Flexbox } from 'react-layout-kit';
7
+
8
+ import { useAgentStore } from '@/store/agent';
9
+ import { agentSelectors } from '@/store/agent/selectors';
10
+ import { useToolStore } from '@/store/tool';
11
+ import { KlavisServer, KlavisServerStatus } from '@/store/tool/slices/klavisStore';
12
+ import { useUserStore } from '@/store/user';
13
+ import { userProfileSelectors } from '@/store/user/selectors';
14
+
15
+ // 轮询配置
16
+ const POLL_INTERVAL_MS = 1000; // 每秒轮询一次
17
+ const POLL_TIMEOUT_MS = 15_000; // 15 秒超时
18
+
19
+ interface KlavisServerItemProps {
20
+ /**
21
+ * Identifier used for storage (e.g., 'google-calendar')
22
+ */
23
+ identifier: string;
24
+ label: string;
25
+ server?: KlavisServer;
26
+ /**
27
+ * Server name used to call Klavis API (e.g., 'Google Calendar')
28
+ */
29
+ serverName: string;
30
+ }
31
+
32
+ const KlavisServerItem = memo<KlavisServerItemProps>(
33
+ ({ identifier, label, server, serverName }) => {
34
+ const { t } = useTranslation('setting');
35
+ const [isConnecting, setIsConnecting] = useState(false);
36
+ const [isToggling, setIsToggling] = useState(false);
37
+ const [isWaitingAuth, setIsWaitingAuth] = useState(false);
38
+
39
+ const oauthWindowRef = useRef<Window | null>(null);
40
+ const windowCheckIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
41
+ const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
42
+ const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
43
+
44
+ const userId = useUserStore(userProfileSelectors.userId);
45
+ const createKlavisServer = useToolStore((s) => s.createKlavisServer);
46
+ const refreshKlavisServerTools = useToolStore((s) => s.refreshKlavisServerTools);
47
+ const removeKlavisServer = useToolStore((s) => s.removeKlavisServer);
48
+
49
+ // 清理所有定时器
50
+ const cleanup = useCallback(() => {
51
+ if (windowCheckIntervalRef.current) {
52
+ clearInterval(windowCheckIntervalRef.current);
53
+ windowCheckIntervalRef.current = null;
54
+ }
55
+ if (pollIntervalRef.current) {
56
+ clearInterval(pollIntervalRef.current);
57
+ pollIntervalRef.current = null;
58
+ }
59
+ if (pollTimeoutRef.current) {
60
+ clearTimeout(pollTimeoutRef.current);
61
+ pollTimeoutRef.current = null;
62
+ }
63
+ oauthWindowRef.current = null;
64
+ setIsWaitingAuth(false);
65
+ }, []);
66
+
67
+ // 组件卸载时清理
68
+ useEffect(() => {
69
+ return () => {
70
+ cleanup();
71
+ };
72
+ }, [cleanup]);
73
+
74
+ // 当服务器状态变为 CONNECTED 时停止所有监听
75
+ useEffect(() => {
76
+ if (server?.status === KlavisServerStatus.CONNECTED && isWaitingAuth) {
77
+ cleanup();
78
+ }
79
+ }, [server?.status, isWaitingAuth, cleanup, t]);
80
+
81
+ /**
82
+ * 启动降级轮询(当 window.closed 不可访问时)
83
+ */
84
+ const startFallbackPolling = useCallback(
85
+ (serverName: string) => {
86
+ // 已经在轮询了,不重复启动
87
+ if (pollIntervalRef.current) return;
88
+
89
+ // 每秒轮询一次
90
+ pollIntervalRef.current = setInterval(async () => {
91
+ try {
92
+ await refreshKlavisServerTools(serverName);
93
+ } catch (error) {
94
+ console.error('[Klavis] Failed to check auth status:', error);
95
+ }
96
+ }, POLL_INTERVAL_MS);
97
+
98
+ // 15 秒后超时停止
99
+ pollTimeoutRef.current = setTimeout(() => {
100
+ if (pollIntervalRef.current) {
101
+ clearInterval(pollIntervalRef.current);
102
+ pollIntervalRef.current = null;
103
+ }
104
+ setIsWaitingAuth(false);
105
+ }, POLL_TIMEOUT_MS);
106
+ },
107
+ [refreshKlavisServerTools, t],
108
+ );
109
+
110
+ /**
111
+ * 监听 OAuth 窗口关闭
112
+ */
113
+ const startWindowMonitor = useCallback(
114
+ (oauthWindow: Window, serverName: string) => {
115
+ // 每 500ms 检查窗口状态
116
+ windowCheckIntervalRef.current = setInterval(() => {
117
+ try {
118
+ // 尝试访问 window.closed(可能被 COOP 阻止)
119
+ if (oauthWindow.closed) {
120
+ // 窗口已关闭,清理监听并检查认证状态
121
+ if (windowCheckIntervalRef.current) {
122
+ clearInterval(windowCheckIntervalRef.current);
123
+ windowCheckIntervalRef.current = null;
124
+ }
125
+ oauthWindowRef.current = null;
126
+
127
+ // 窗口关闭后立即检查一次认证状态
128
+ refreshKlavisServerTools(serverName);
129
+ }
130
+ } catch {
131
+ // COOP 阻止了访问,降级到轮询方案
132
+ console.log('[Klavis] COOP blocked window.closed access, falling back to polling');
133
+ if (windowCheckIntervalRef.current) {
134
+ clearInterval(windowCheckIntervalRef.current);
135
+ windowCheckIntervalRef.current = null;
136
+ }
137
+ startFallbackPolling(serverName);
138
+ }
139
+ }, 500);
140
+ },
141
+ [refreshKlavisServerTools, startFallbackPolling],
142
+ );
143
+
144
+ /**
145
+ * 打开 OAuth 窗口
146
+ */
147
+ const openOAuthWindow = useCallback(
148
+ (oauthUrl: string, serverName: string) => {
149
+ // 清理之前的状态
150
+ cleanup();
151
+ setIsWaitingAuth(true);
152
+
153
+ // 打开 OAuth 窗口
154
+ const oauthWindow = window.open(oauthUrl, '_blank', 'width=600,height=700');
155
+ if (oauthWindow) {
156
+ oauthWindowRef.current = oauthWindow;
157
+ startWindowMonitor(oauthWindow, serverName);
158
+ } else {
159
+ // 窗口被阻止,直接用轮询
160
+ startFallbackPolling(serverName);
161
+ }
162
+ },
163
+ [cleanup, startWindowMonitor, startFallbackPolling, t],
164
+ );
165
+
166
+ // Get plugin ID for this server (使用 identifier 作为 pluginId)
167
+ const pluginId = server ? server.identifier : '';
168
+ const [checked, togglePlugin] = useAgentStore((s) => [
169
+ agentSelectors.currentAgentPlugins(s).includes(pluginId),
170
+ s.togglePlugin,
171
+ ]);
172
+
173
+ const handleConnect = async () => {
174
+ if (!userId) {
175
+ return;
176
+ }
177
+
178
+ if (server) {
179
+ return;
180
+ }
181
+
182
+ setIsConnecting(true);
183
+ try {
184
+ const newServer = await createKlavisServer({
185
+ identifier,
186
+ serverName,
187
+ userId,
188
+ });
189
+
190
+ if (newServer) {
191
+ // 安装完成后自动启用插件(使用 identifier)
192
+ const newPluginId = newServer.identifier;
193
+ await togglePlugin(newPluginId);
194
+
195
+ // 如果已认证,直接刷新工具列表,跳过 OAuth
196
+ if (newServer.isAuthenticated) {
197
+ await refreshKlavisServerTools(newServer.identifier);
198
+ } else if (newServer.oauthUrl) {
199
+ // 需要 OAuth,打开 OAuth 窗口并监听关闭
200
+ openOAuthWindow(newServer.oauthUrl, newServer.identifier);
201
+ }
202
+ }
203
+ } catch (error) {
204
+ console.error('[Klavis] Failed to connect server:', error);
205
+ } finally {
206
+ setIsConnecting(false);
207
+ }
208
+ };
209
+
210
+ const handleToggle = async () => {
211
+ if (!server) return;
212
+ setIsToggling(true);
213
+ await togglePlugin(pluginId);
214
+ setIsToggling(false);
215
+ };
216
+
217
+ const handleDisconnect = async () => {
218
+ if (!server) return;
219
+ setIsToggling(true);
220
+ // 如果当前已启用,先禁用
221
+ if (checked) {
222
+ await togglePlugin(pluginId);
223
+ }
224
+ // 删除服务器(使用 identifier)
225
+ await removeKlavisServer(server.identifier);
226
+ setIsToggling(false);
227
+ };
228
+
229
+ // 渲染右侧控件
230
+ const renderRightControl = () => {
231
+ // 正在连接中
232
+ if (isConnecting) {
233
+ return (
234
+ <Flexbox align="center" gap={4} horizontal onClick={(e) => e.stopPropagation()}>
235
+ <Icon icon={Loader2} spin />
236
+ </Flexbox>
237
+ );
238
+ }
239
+
240
+ // 未连接,显示 Connect 按钮
241
+ if (!server) {
242
+ return (
243
+ <Flexbox
244
+ align="center"
245
+ gap={4}
246
+ horizontal
247
+ onClick={(e) => {
248
+ e.stopPropagation();
249
+ handleConnect();
250
+ }}
251
+ style={{ cursor: 'pointer', opacity: 0.65 }}
252
+ >
253
+ {t('tools.klavis.connect', { defaultValue: 'Connect' })}
254
+ <Icon icon={SquareArrowOutUpRight} size="small" />
255
+ </Flexbox>
256
+ );
257
+ }
258
+
259
+ // 根据状态显示不同控件
260
+ switch (server.status) {
261
+ case KlavisServerStatus.CONNECTED: {
262
+ // 正在切换状态
263
+ if (isToggling) {
264
+ return <Icon icon={Loader2} spin />;
265
+ }
266
+ return (
267
+ <Flexbox align="center" gap={8} horizontal>
268
+ <Icon
269
+ icon={Unplug}
270
+ onClick={(e) => {
271
+ e.stopPropagation();
272
+ handleDisconnect();
273
+ }}
274
+ size="small"
275
+ style={{ cursor: 'pointer', opacity: 0.5 }}
276
+ />
277
+ <Checkbox
278
+ checked={checked}
279
+ onClick={(e) => {
280
+ e.stopPropagation();
281
+ handleToggle();
282
+ }}
283
+ />
284
+ </Flexbox>
285
+ );
286
+ }
287
+ case KlavisServerStatus.PENDING_AUTH: {
288
+ // 正在等待认证
289
+ if (isWaitingAuth) {
290
+ return (
291
+ <Flexbox align="center" gap={4} horizontal onClick={(e) => e.stopPropagation()}>
292
+ <Icon icon={Loader2} spin />
293
+ </Flexbox>
294
+ );
295
+ }
296
+ return (
297
+ <Flexbox
298
+ align="center"
299
+ gap={4}
300
+ horizontal
301
+ onClick={(e) => {
302
+ e.stopPropagation();
303
+ // 点击重新打开 OAuth 窗口
304
+ if (server.oauthUrl) {
305
+ openOAuthWindow(server.oauthUrl, server.identifier);
306
+ }
307
+ }}
308
+ style={{ cursor: 'pointer', opacity: 0.65 }}
309
+ >
310
+ {t('tools.klavis.pendingAuth', { defaultValue: 'Authorize' })}
311
+ <Icon icon={SquareArrowOutUpRight} size="small" />
312
+ </Flexbox>
313
+ );
314
+ }
315
+ case KlavisServerStatus.ERROR: {
316
+ return (
317
+ <span style={{ color: 'red', fontSize: 12 }}>
318
+ {t('tools.klavis.error', { defaultValue: 'Error' })}
319
+ </span>
320
+ );
321
+ }
322
+ default: {
323
+ return null;
324
+ }
325
+ }
326
+ };
327
+
328
+ return (
329
+ <Flexbox
330
+ gap={24}
331
+ horizontal
332
+ justify={'space-between'}
333
+ onClick={(e) => {
334
+ e.stopPropagation();
335
+ // 如果已连接,点击整行切换状态
336
+ if (server?.status === KlavisServerStatus.CONNECTED) {
337
+ handleToggle();
338
+ }
339
+ }}
340
+ style={{ paddingLeft: 8 }}
341
+ >
342
+ <Flexbox align={'center'} gap={8} horizontal>
343
+ {label}
344
+ </Flexbox>
345
+ {renderRightControl()}
346
+ </Flexbox>
347
+ );
348
+ },
349
+ );
350
+
351
+ export default KlavisServerItem;
@@ -1,22 +1,43 @@
1
+ import { Segmented } from '@lobehub/ui';
1
2
  import { Blocks } from 'lucide-react';
2
- import { Suspense, memo, useState } from 'react';
3
+ import { Suspense, memo, useEffect, useRef, useState } from 'react';
3
4
  import { useTranslation } from 'react-i18next';
4
5
 
5
6
  import PluginStore from '@/features/PluginStore';
6
7
  import { useModelSupportToolUse } from '@/hooks/useModelSupportToolUse';
7
8
  import { useAgentStore } from '@/store/agent';
8
9
  import { agentSelectors } from '@/store/agent/selectors';
9
- import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
10
+ import {
11
+ featureFlagsSelectors,
12
+ serverConfigSelectors,
13
+ useServerConfigStore,
14
+ } from '@/store/serverConfig';
10
15
 
11
16
  import Action from '../components/Action';
12
17
  import { useControls } from './useControls';
13
18
 
19
+ type TabType = 'all' | 'installed';
20
+
14
21
  const Tools = memo(() => {
15
22
  const { t } = useTranslation('setting');
16
23
  const [modalOpen, setModalOpen] = useState(false);
17
24
  const [updating, setUpdating] = useState(false);
18
- const items = useControls({ setModalOpen, setUpdating });
25
+ const [activeTab, setActiveTab] = useState<TabType | null>(null);
26
+ const { marketItems, installedPluginItems } = useControls({
27
+ setModalOpen,
28
+ setUpdating,
29
+ });
19
30
  const { enablePlugins } = useServerConfigStore(featureFlagsSelectors);
31
+ const enableKlavis = useServerConfigStore(serverConfigSelectors.enableKlavis);
32
+ const isInitializedRef = useRef(false);
33
+
34
+ // Set default tab based on installed plugins (only on first load)
35
+ useEffect(() => {
36
+ if (!isInitializedRef.current && installedPluginItems.length >= 0) {
37
+ isInitializedRef.current = true;
38
+ setActiveTab(installedPluginItems.length > 0 ? 'installed' : 'all');
39
+ }
40
+ }, [installedPluginItems.length]);
20
41
 
21
42
  const model = useAgentStore(agentSelectors.currentAgentModel);
22
43
  const provider = useAgentStore(agentSelectors.currentAgentModelProvider);
@@ -27,13 +48,44 @@ const Tools = memo(() => {
27
48
  if (!enableFC)
28
49
  return <Action disabled icon={Blocks} showTooltip={true} title={t('tools.disabled')} />;
29
50
 
51
+ // Use effective tab for display (default to market while initializing)
52
+ const effectiveTab = activeTab ?? 'all';
53
+ const currentItems = effectiveTab === 'all' ? marketItems : installedPluginItems;
54
+
30
55
  return (
31
56
  <Suspense fallback={<Action disabled icon={Blocks} title={t('tools.title')} />}>
32
57
  <Action
33
58
  dropdown={{
34
59
  maxHeight: 500,
35
60
  maxWidth: 480,
36
- menu: { items },
61
+ menu: {
62
+ items: [
63
+ {
64
+ key: 'tabs',
65
+ label: (
66
+ <Segmented
67
+ block
68
+ onChange={(v) => setActiveTab(v as TabType)}
69
+ options={[
70
+ {
71
+ label: t('tools.tabs.all', { defaultValue: 'all' }),
72
+ value: 'all',
73
+ },
74
+ {
75
+ label: t('tools.tabs.installed', { defaultValue: 'Installed' }),
76
+ value: 'installed',
77
+ },
78
+ ]}
79
+ size="small"
80
+ value={effectiveTab}
81
+ />
82
+ ),
83
+ type: 'group',
84
+ },
85
+ ...currentItems,
86
+ ],
87
+ },
88
+ minHeight: enableKlavis ? 500 : undefined,
37
89
  minWidth: 320,
38
90
  }}
39
91
  icon={Blocks}