@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
@@ -1,6 +1,10 @@
1
+ import { KLAVIS_SERVER_TYPES, KlavisServerType } from '@lobechat/const';
1
2
  import { Avatar, Icon, ItemType } from '@lobehub/ui';
3
+ import { useTheme } from 'antd-style';
2
4
  import isEqual from 'fast-deep-equal';
3
5
  import { ArrowRight, Store, ToyBrick } from 'lucide-react';
6
+ import Image from 'next/image';
7
+ import { memo, useMemo } from 'react';
4
8
  import { useTranslation } from 'react-i18next';
5
9
  import { Flexbox } from 'react-layout-kit';
6
10
 
@@ -9,11 +13,37 @@ import { useCheckPluginsIsInstalled } from '@/hooks/useCheckPluginsIsInstalled';
9
13
  import { useFetchInstalledPlugins } from '@/hooks/useFetchInstalledPlugins';
10
14
  import { useAgentStore } from '@/store/agent';
11
15
  import { agentSelectors } from '@/store/agent/selectors';
16
+ import { serverConfigSelectors, useServerConfigStore } from '@/store/serverConfig';
12
17
  import { useToolStore } from '@/store/tool';
13
- import { builtinToolSelectors, pluginSelectors } from '@/store/tool/selectors';
18
+ import {
19
+ builtinToolSelectors,
20
+ klavisStoreSelectors,
21
+ pluginSelectors,
22
+ } from '@/store/tool/selectors';
14
23
 
24
+ import KlavisServerItem from './KlavisServerItem';
15
25
  import ToolItem from './ToolItem';
16
26
 
27
+ /**
28
+ * Klavis 服务器图标组件
29
+ * 对于 string 类型的 icon,使用 Image 组件渲染
30
+ * 对于 IconType 类型的 icon,使用 Icon 组件渲染,并根据主题设置填充色
31
+ */
32
+ const KlavisIcon = memo<Pick<KlavisServerType, 'icon' | 'label'>>(({ icon, label }) => {
33
+ const theme = useTheme();
34
+
35
+ if (typeof icon === 'string') {
36
+ return (
37
+ <Image alt={label} height={18} src={icon} style={{ flex: 'none' }} unoptimized width={18} />
38
+ );
39
+ }
40
+
41
+ // 使用主题色填充,在深色模式下自动适应
42
+ return <Icon fill={theme.colorText} icon={icon} size={18} />;
43
+ });
44
+
45
+ KlavisIcon.displayName = 'KlavisIcon';
46
+
17
47
  export const useControls = ({
18
48
  setModalOpen,
19
49
  setUpdating,
@@ -36,15 +66,67 @@ export const useControls = ({
36
66
  );
37
67
  const plugins = useAgentStore((s) => agentSelectors.currentAgentPlugins(s));
38
68
 
39
- const [useFetchPluginStore] = useToolStore((s) => [s.useFetchPluginStore]);
69
+ // Klavis 相关状态
70
+ const allKlavisServers = useToolStore(klavisStoreSelectors.getServers, isEqual);
71
+ const isKlavisEnabledInEnv = useServerConfigStore(serverConfigSelectors.enableKlavis);
72
+
73
+ const [useFetchPluginStore, useFetchUserKlavisServers] = useToolStore((s) => [
74
+ s.useFetchPluginStore,
75
+ s.useFetchUserKlavisServers,
76
+ ]);
40
77
 
41
78
  useFetchPluginStore();
42
79
  useFetchInstalledPlugins();
43
80
  useCheckPluginsIsInstalled(plugins);
44
81
 
45
- const items: ItemType[] = [
46
- {
47
- children: builtinList.map((item) => ({
82
+ // 使用 SWR 加载用户的 Klavis 集成(从数据库)
83
+ useFetchUserKlavisServers(isKlavisEnabledInEnv);
84
+
85
+ // 根据 identifier 获取已连接的服务器
86
+ const getServerByName = (identifier: string) => {
87
+ return allKlavisServers.find((server) => server.identifier === identifier);
88
+ };
89
+
90
+ // 获取所有 Klavis 服务器类型的 identifier 集合(用于过滤 builtinList)
91
+ // 这里使用 KLAVIS_SERVER_TYPES 而不是已连接的服务器,因为我们要过滤掉所有可能的 Klavis 类型
92
+ const allKlavisTypeIdentifiers = useMemo(
93
+ () => new Set(KLAVIS_SERVER_TYPES.map((type) => type.identifier)),
94
+ [],
95
+ );
96
+ // 过滤掉 builtinList 中的 klavis 工具(它们会单独显示在 Klavis 区域)
97
+ const filteredBuiltinList = useMemo(
98
+ () =>
99
+ isKlavisEnabledInEnv
100
+ ? builtinList.filter((item) => !allKlavisTypeIdentifiers.has(item.identifier))
101
+ : builtinList,
102
+ [builtinList, allKlavisTypeIdentifiers, isKlavisEnabledInEnv],
103
+ );
104
+
105
+ // Klavis 服务器列表项
106
+ const klavisServerItems = useMemo(
107
+ () =>
108
+ isKlavisEnabledInEnv
109
+ ? KLAVIS_SERVER_TYPES.map((type) => ({
110
+ icon: <KlavisIcon icon={type.icon} label={type.label} />,
111
+ key: type.identifier,
112
+ label: (
113
+ <KlavisServerItem
114
+ identifier={type.identifier}
115
+ label={type.label}
116
+ server={getServerByName(type.identifier)}
117
+ serverName={type.serverName}
118
+ />
119
+ ),
120
+ }))
121
+ : [],
122
+ [isKlavisEnabledInEnv, allKlavisServers],
123
+ );
124
+
125
+ // 合并 builtin 工具和 Klavis 服务器
126
+ const builtinItems = useMemo(
127
+ () => [
128
+ // 原有的 builtin 工具
129
+ ...filteredBuiltinList.map((item) => ({
48
130
  icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
49
131
  key: item.identifier,
50
132
  label: (
@@ -60,7 +142,16 @@ export const useControls = ({
60
142
  />
61
143
  ),
62
144
  })),
145
+ // Klavis 服务器
146
+ ...klavisServerItems,
147
+ ],
148
+ [filteredBuiltinList, klavisServerItems, checked, togglePlugin, setUpdating],
149
+ );
63
150
 
151
+ // 市场 tab 的 items
152
+ const marketItems: ItemType[] = [
153
+ {
154
+ children: builtinItems,
64
155
  key: 'builtins',
65
156
  label: t('tools.builtins.groupName'),
66
157
  type: 'group',
@@ -113,5 +204,82 @@ export const useControls = ({
113
204
  },
114
205
  ];
115
206
 
116
- return items;
207
+ // 已安装 tab 的 items - 只显示已安装的插件
208
+ const installedPluginItems: ItemType[] = useMemo(() => {
209
+ const installedItems: ItemType[] = [];
210
+
211
+ // 已安装的 builtin 工具
212
+ const enabledBuiltinItems = filteredBuiltinList
213
+ .filter((item) => checked.includes(item.identifier))
214
+ .map((item) => ({
215
+ icon: <Avatar avatar={item.meta.avatar} size={20} style={{ flex: 'none' }} />,
216
+ key: item.identifier,
217
+ label: (
218
+ <ToolItem
219
+ checked={true}
220
+ id={item.identifier}
221
+ label={item.meta?.title}
222
+ onUpdate={async () => {
223
+ setUpdating(true);
224
+ await togglePlugin(item.identifier);
225
+ setUpdating(false);
226
+ }}
227
+ />
228
+ ),
229
+ }));
230
+
231
+ // 已连接的 Klavis 服务器(放在 builtin 里面)
232
+ const connectedKlavisItems = klavisServerItems.filter((item) =>
233
+ checked.includes(item.key as string),
234
+ );
235
+
236
+ // 合并 builtin 和 Klavis
237
+ const allBuiltinItems = [...enabledBuiltinItems, ...connectedKlavisItems];
238
+
239
+ if (allBuiltinItems.length > 0) {
240
+ installedItems.push({
241
+ children: allBuiltinItems,
242
+ key: 'installed-builtins',
243
+ label: t('tools.builtins.groupName'),
244
+ type: 'group',
245
+ });
246
+ }
247
+
248
+ // 已安装的插件
249
+ const installedPlugins = list
250
+ .filter((item) => checked.includes(item.identifier))
251
+ .map((item) => ({
252
+ icon: item?.avatar ? (
253
+ <PluginAvatar avatar={item.avatar} size={20} />
254
+ ) : (
255
+ <Icon icon={ToyBrick} size={20} />
256
+ ),
257
+ key: item.identifier,
258
+ label: (
259
+ <ToolItem
260
+ checked={true}
261
+ id={item.identifier}
262
+ label={item.title}
263
+ onUpdate={async () => {
264
+ setUpdating(true);
265
+ await togglePlugin(item.identifier);
266
+ setUpdating(false);
267
+ }}
268
+ />
269
+ ),
270
+ }));
271
+
272
+ if (installedPlugins.length > 0) {
273
+ installedItems.push({
274
+ children: installedPlugins,
275
+ key: 'installed-plugins',
276
+ label: t('tools.plugins.groupName'),
277
+ type: 'group',
278
+ });
279
+ }
280
+
281
+ return installedItems;
282
+ }, [filteredBuiltinList, list, klavisServerItems, checked, togglePlugin, setUpdating, t]);
283
+
284
+ return { installedPluginItems, marketItems };
117
285
  };
@@ -22,6 +22,7 @@ const useStyles = createStyles(({ css, prefixCls }) => ({
22
22
  export interface ActionDropdownProps extends DropdownProps {
23
23
  maxHeight?: number | string;
24
24
  maxWidth?: number | string;
25
+ minHeight?: number | string;
25
26
  minWidth?: number | string;
26
27
  /**
27
28
  * 是否在挂载时预渲染弹层,避免首次触发展开时的渲染卡顿
@@ -30,7 +31,7 @@ export interface ActionDropdownProps extends DropdownProps {
30
31
  }
31
32
 
32
33
  const ActionDropdown = memo<ActionDropdownProps>(
33
- ({ menu, maxHeight, minWidth, maxWidth, children, placement = 'top', ...rest }) => {
34
+ ({ menu, maxHeight, minWidth, maxWidth, children, placement = 'top', minHeight, ...rest }) => {
34
35
  const { cx, styles } = useStyles();
35
36
  const isMobile = useIsMobile();
36
37
 
@@ -48,6 +49,7 @@ const ActionDropdown = memo<ActionDropdownProps>(
48
49
  style: {
49
50
  maxHeight,
50
51
  maxWidth: isMobile ? undefined : maxWidth,
52
+ minHeight,
51
53
  minWidth: isMobile ? undefined : minWidth,
52
54
  overflowX: 'hidden',
53
55
  overflowY: 'scroll',
@@ -67,6 +67,9 @@ vi.mock('@/store/tool/selectors', () => ({
67
67
  pluginSelectors: {
68
68
  installedPluginManifestList: () => [],
69
69
  },
70
+ klavisStoreSelectors: {
71
+ klavisAsLobeTools: () => [],
72
+ },
70
73
  }));
71
74
 
72
75
  vi.mock('../isCanUseFC', () => ({
@@ -9,7 +9,7 @@ import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
9
9
  import { getAgentStoreState } from '@/store/agent';
10
10
  import { agentSelectors } from '@/store/agent/selectors';
11
11
  import { getToolStoreState } from '@/store/tool';
12
- import { pluginSelectors } from '@/store/tool/selectors';
12
+ import { klavisStoreSelectors, pluginSelectors } from '@/store/tool/selectors';
13
13
  import { KnowledgeBaseManifest } from '@/tools/knowledge-base';
14
14
  import { WebBrowsingManifest } from '@/tools/web-browsing';
15
15
 
@@ -45,8 +45,19 @@ export const createToolsEngine = (config: ToolsEngineConfig = {}): ToolsEngine =
45
45
  (tool) => tool.manifest as LobeChatPluginManifest,
46
46
  );
47
47
 
48
+ // Get Klavis tool manifests
49
+ const klavisTools = klavisStoreSelectors.klavisAsLobeTools(toolStoreState);
50
+ const klavisManifests = klavisTools
51
+ .map((tool) => tool.manifest as LobeChatPluginManifest)
52
+ .filter(Boolean);
53
+
48
54
  // Combine all manifests
49
- const allManifests = [...pluginManifests, ...builtinManifests, ...additionalManifests];
55
+ const allManifests = [
56
+ ...pluginManifests,
57
+ ...builtinManifests,
58
+ ...klavisManifests,
59
+ ...additionalManifests,
60
+ ];
50
61
 
51
62
  return new ToolsEngine({
52
63
  defaultToolIds,
@@ -0,0 +1,91 @@
1
+ import { authEnv } from '@/envs/auth';
2
+ import { getRedisConfig } from '@/envs/redis';
3
+ import { initializeRedis, isRedisEnabled } from '@/libs/redis';
4
+
5
+ const APPLE_TRUSTED_ORIGIN = 'https://appleid.apple.com';
6
+
7
+ /**
8
+ * Normalize a URL-like string to an origin with https fallback.
9
+ */
10
+ export const normalizeOrigin = (url?: string) => {
11
+ if (!url) return undefined;
12
+
13
+ try {
14
+ const normalizedUrl = url.startsWith('http') ? url : `https://${url}`;
15
+
16
+ return new URL(normalizedUrl).origin;
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ };
21
+
22
+ /**
23
+ * Build trusted origins with env override and Vercel-aware defaults.
24
+ */
25
+ export const getTrustedOrigins = (enabledSSOProviders: string[]) => {
26
+ if (authEnv.AUTH_TRUSTED_ORIGINS) {
27
+ const originsFromEnv = authEnv.AUTH_TRUSTED_ORIGINS.split(',')
28
+ .map((item) => normalizeOrigin(item.trim()))
29
+ .filter(Boolean) as string[];
30
+
31
+ if (originsFromEnv.length > 0) return Array.from(new Set(originsFromEnv));
32
+ }
33
+
34
+ const defaults = [
35
+ authEnv.NEXT_PUBLIC_AUTH_URL,
36
+ normalizeOrigin(process.env.APP_URL),
37
+ normalizeOrigin(process.env.VERCEL_BRANCH_URL),
38
+ normalizeOrigin(process.env.VERCEL_URL),
39
+ ].filter(Boolean) as string[];
40
+
41
+ const baseTrustedOrigins = defaults.length > 0 ? Array.from(new Set(defaults)) : undefined;
42
+
43
+ if (!enabledSSOProviders.includes('apple')) return baseTrustedOrigins;
44
+
45
+ const mergedOrigins = new Set(baseTrustedOrigins || []);
46
+ mergedOrigins.add(APPLE_TRUSTED_ORIGIN);
47
+
48
+ return Array.from(mergedOrigins);
49
+ };
50
+
51
+ /**
52
+ * Build Better Auth secondaryStorage backed by Redis.
53
+ * Uses the shared Redis manager to avoid duplicate connections and prefixes keys to prevent clashes.
54
+ */
55
+ export const createSecondaryStorage = () => {
56
+ const redisConfig = getRedisConfig();
57
+ if (!isRedisEnabled(redisConfig)) return undefined;
58
+
59
+ const secondaryStorageKeyPrefix = 'better-auth:';
60
+
61
+ const buildKey = (key: string) => `${secondaryStorageKeyPrefix}${key}`;
62
+
63
+ const getRedisClient = async () => {
64
+ const redisClient = await initializeRedis(redisConfig);
65
+ if (!redisClient) {
66
+ throw new Error('Redis secondary storage is enabled but failed to initialize');
67
+ }
68
+
69
+ return redisClient;
70
+ };
71
+
72
+ return {
73
+ delete: async (key: string) => {
74
+ const redisClient = await getRedisClient();
75
+ await redisClient.del(buildKey(key));
76
+ },
77
+ get: async (key: string) => {
78
+ const redisClient = await getRedisClient();
79
+ return (await redisClient.get(buildKey(key))) ?? null;
80
+ },
81
+ set: async (key: string, value: string, ttl?: number) => {
82
+ const redisClient = await getRedisClient();
83
+ if (typeof ttl === 'number') {
84
+ await redisClient.set(buildKey(key), value, { ex: ttl });
85
+ return;
86
+ }
87
+
88
+ await redisClient.set(buildKey(key), value);
89
+ },
90
+ };
91
+ };
@@ -0,0 +1,36 @@
1
+ import { KlavisClient } from 'klavis';
2
+
3
+ import { getServerKlavisApiKey } from '@/config/klavis';
4
+
5
+ /**
6
+ * Global Klavis Client instance cache (server-side only)
7
+ */
8
+ let klavisClientInstance: { apiKey: string; client: KlavisClient } | undefined;
9
+
10
+ /**
11
+ * Get or create Klavis Client instance (server-side only)
12
+ * The instance is cached and reused if the API key hasn't changed
13
+ */
14
+ export const getKlavisClient = (): KlavisClient => {
15
+ const apiKey = getServerKlavisApiKey();
16
+
17
+ if (!apiKey) {
18
+ throw new Error('Klavis API key is not configured on server');
19
+ }
20
+
21
+ if (!klavisClientInstance || klavisClientInstance.apiKey !== apiKey) {
22
+ klavisClientInstance = {
23
+ apiKey,
24
+ client: new KlavisClient({ apiKey }),
25
+ };
26
+ }
27
+
28
+ return klavisClientInstance.client;
29
+ };
30
+
31
+ /**
32
+ * Check if Klavis client is available (has API key configured)
33
+ */
34
+ export const isKlavisClientAvailable = (): boolean => {
35
+ return !!getServerKlavisApiKey();
36
+ };
@@ -23,7 +23,11 @@ class RedisManager {
23
23
  if (config.provider === 'redis') {
24
24
  provider = new IoRedisRedisProvider(config);
25
25
  } else if (config.provider === 'upstash') {
26
- provider = new UpstashRedisProvider({ token: config.token, url: config.url });
26
+ provider = new UpstashRedisProvider({
27
+ prefix: config.prefix,
28
+ token: config.token,
29
+ url: config.url,
30
+ });
27
31
  } else {
28
32
  throw new Error(`Unsupported redis provider: ${String((config as any).provider)}`);
29
33
  }
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from 'vitest';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  import { IoRedisConfig } from './types';
4
4
 
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Read more here: https://github.com/capricorn86/happy-dom/issues/1042#issuecomment-3585851354
7
7
  import { Buffer } from 'node:buffer';
8
- import { describe, expect, it, vi } from 'vitest';
8
+ import { afterEach, describe, expect, it, vi } from 'vitest';
9
9
 
10
10
  import { UpstashConfig } from './types';
11
11
 
@@ -139,9 +139,9 @@ describe('mocked', () => {
139
139
  await provider.hset(bufKey, 'field', 'value');
140
140
  await provider.del(bufKey);
141
141
 
142
- expect(mocks.mockSet).toHaveBeenCalledWith('buffer-key', 'value', undefined);
143
- expect(mocks.mockHset).toHaveBeenCalledWith('buffer-key', { field: 'value' });
144
- expect(mocks.mockDel).toHaveBeenCalledWith('buffer-key');
142
+ expect(mocks.mockSet).toHaveBeenCalledWith('mock:buffer-key', 'value', undefined);
143
+ expect(mocks.mockHset).toHaveBeenCalledWith('mock:buffer-key', { field: 'value' });
144
+ expect(mocks.mockDel).toHaveBeenCalledWith('mock:buffer-key');
145
145
  });
146
146
 
147
147
  it('passes set options through to upstash client', async () => {
@@ -149,6 +149,10 @@ describe('mocked', () => {
149
149
 
150
150
  await provider.set('key', 'value', { ex: 10, nx: true, get: true });
151
151
 
152
- expect(mocks.mockSet).toHaveBeenCalledWith('key', 'value', { ex: 10, nx: true, get: true });
152
+ expect(mocks.mockSet).toHaveBeenCalledWith('mock:key', 'value', {
153
+ ex: 10,
154
+ nx: true,
155
+ get: true,
156
+ });
153
157
  });
154
158
  });
@@ -20,9 +20,28 @@ import {
20
20
  export class UpstashRedisProvider implements BaseRedisProvider {
21
21
  provider: 'upstash' = 'upstash';
22
22
  private client: Redis;
23
+ private readonly prefix: string;
23
24
 
24
25
  constructor(options: UpstashConfig | RedisConfigNodejs) {
25
- this.client = new Redis(options as RedisConfigNodejs);
26
+ const { prefix, ...clientOptions } = options as UpstashConfig & RedisConfigNodejs;
27
+ this.prefix = prefix ? `${prefix}:` : '';
28
+ this.client = new Redis(clientOptions as RedisConfigNodejs);
29
+ }
30
+
31
+ /**
32
+ * Build a fully qualified key assuming the input was already normalized.
33
+ * Avoids re-running normalization when callers have normalized keys (e.g. mset).
34
+ */
35
+ private addPrefixToKey(normalizedKey: string) {
36
+ return `${this.prefix}${normalizedKey}`;
37
+ }
38
+
39
+ private buildKey(key: RedisKey) {
40
+ return this.addPrefixToKey(normalizeRedisKey(key));
41
+ }
42
+
43
+ private buildKeys(keys: RedisKey[]) {
44
+ return normalizeRedisKeys(keys).map((key) => `${this.prefix}${key}`);
26
45
  }
27
46
 
28
47
  async initialize(): Promise<void> {
@@ -34,15 +53,11 @@ export class UpstashRedisProvider implements BaseRedisProvider {
34
53
  }
35
54
 
36
55
  async get(key: RedisKey): Promise<string | null> {
37
- return this.client.get(normalizeRedisKey(key));
56
+ return this.client.get(this.buildKey(key));
38
57
  }
39
58
 
40
59
  async set(key: RedisKey, value: RedisValue, options?: SetOptions): Promise<RedisSetResult> {
41
- const res = await this.client.set(
42
- normalizeRedisKey(key),
43
- value,
44
- buildUpstashSetOptions(options),
45
- );
60
+ const res = await this.client.set(this.buildKey(key), value, buildUpstashSetOptions(options));
46
61
  if (Buffer.isBuffer(res)) {
47
62
  return res.toString();
48
63
  }
@@ -51,55 +66,64 @@ export class UpstashRedisProvider implements BaseRedisProvider {
51
66
  }
52
67
 
53
68
  async setex(key: RedisKey, seconds: number, value: RedisValue): Promise<'OK'> {
54
- return this.client.setex(normalizeRedisKey(key), seconds, value);
69
+ return this.client.setex(this.buildKey(key), seconds, value);
55
70
  }
56
71
 
57
72
  async del(...keys: RedisKey[]): Promise<number> {
58
- return this.client.del(...normalizeRedisKeys(keys));
73
+ return this.client.del(...this.buildKeys(keys));
59
74
  }
60
75
 
61
76
  async exists(...keys: RedisKey[]): Promise<number> {
62
- return this.client.exists(...normalizeRedisKeys(keys));
77
+ return this.client.exists(...this.buildKeys(keys));
63
78
  }
64
79
 
65
80
  async expire(key: RedisKey, seconds: number): Promise<number> {
66
- return this.client.expire(normalizeRedisKey(key), seconds);
81
+ return this.client.expire(this.buildKey(key), seconds);
67
82
  }
68
83
 
69
84
  async ttl(key: RedisKey): Promise<number> {
70
- return this.client.ttl(normalizeRedisKey(key));
85
+ return this.client.ttl(this.buildKey(key));
71
86
  }
72
87
 
73
88
  async incr(key: RedisKey): Promise<number> {
74
- return this.client.incr(normalizeRedisKey(key));
89
+ return this.client.incr(this.buildKey(key));
75
90
  }
76
91
 
77
92
  async decr(key: RedisKey): Promise<number> {
78
- return this.client.decr(normalizeRedisKey(key));
93
+ return this.client.decr(this.buildKey(key));
79
94
  }
80
95
 
81
96
  async mget(...keys: RedisKey[]): Promise<(string | null)[]> {
82
- return this.client.mget(...normalizeRedisKeys(keys));
97
+ return this.client.mget(...this.buildKeys(keys));
83
98
  }
84
99
 
85
100
  async mset(values: RedisMSetArgument): Promise<'OK'> {
86
- return this.client.mset(normalizeMsetValues(values));
101
+ const normalized = normalizeMsetValues(values);
102
+ const prefixed = Object.entries(normalized).reduce<Record<string, RedisValue>>(
103
+ (acc, [key, value]) => {
104
+ acc[this.addPrefixToKey(key)] = value;
105
+ return acc;
106
+ },
107
+ {},
108
+ );
109
+
110
+ return this.client.mset(prefixed);
87
111
  }
88
112
 
89
113
  async hget(key: RedisKey, field: RedisKey): Promise<string | null> {
90
- return this.client.hget(normalizeRedisKey(key), normalizeRedisKey(field));
114
+ return this.client.hget(this.buildKey(key), normalizeRedisKey(field));
91
115
  }
92
116
 
93
117
  async hset(key: RedisKey, field: RedisKey, value: RedisValue): Promise<number> {
94
- return this.client.hset(normalizeRedisKey(key), { [normalizeRedisKey(field)]: value });
118
+ return this.client.hset(this.buildKey(key), { [normalizeRedisKey(field)]: value });
95
119
  }
96
120
 
97
121
  async hdel(key: RedisKey, ...fields: RedisKey[]): Promise<number> {
98
- return this.client.hdel(normalizeRedisKey(key), ...normalizeRedisKeys(fields));
122
+ return this.client.hdel(this.buildKey(key), ...normalizeRedisKeys(fields));
99
123
  }
100
124
 
101
125
  async hgetall(key: RedisKey): Promise<Record<string, string>> {
102
- const res = await this.client.hgetall(normalizeRedisKey(key));
126
+ const res = await this.client.hgetall(this.buildKey(key));
103
127
  if (!res) {
104
128
  return {};
105
129
  }
@@ -0,0 +1,40 @@
1
+ export default {
2
+ actions: {
3
+ discord: '前往 Discord 反馈',
4
+ home: '返回首页',
5
+ retry: '重新登录',
6
+ },
7
+ codes: {
8
+ ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER: '该账号已关联至其他用户',
9
+ ACCOUNT_NOT_FOUND: '未找到对应账号',
10
+ CREDENTIAL_ACCOUNT_NOT_FOUND: '凭证账号不存在',
11
+ EMAIL_CAN_NOT_BE_UPDATED: '当前账号邮箱不可修改',
12
+ EMAIL_NOT_VERIFIED: '请先完成邮箱验证',
13
+ FAILED_TO_CREATE_SESSION: '创建会话失败',
14
+ FAILED_TO_CREATE_USER: '创建用户失败',
15
+ FAILED_TO_GET_SESSION: '获取会话失败',
16
+ FAILED_TO_GET_USER_INFO: '获取用户信息失败',
17
+ FAILED_TO_UNLINK_LAST_ACCOUNT: '无法解绑最后一个关联账号',
18
+ FAILED_TO_UPDATE_USER: '更新用户信息失败',
19
+ ID_TOKEN_NOT_SUPPORTED: '当前身份令牌不被支持',
20
+ INVALID_EMAIL: '邮箱格式不正确',
21
+ INVALID_EMAIL_OR_PASSWORD: '邮箱或密码错误',
22
+ INVALID_PASSWORD: '密码格式无效',
23
+ INVALID_TOKEN: '令牌无效或已过期',
24
+ PASSWORD_TOO_LONG: '密码长度过长',
25
+ PASSWORD_TOO_SHORT: '密码长度过短',
26
+ PROVIDER_NOT_FOUND: '未找到对应的身份提供方配置',
27
+ RATE_LIMIT_EXCEEDED: '请求过于频繁,请稍后再试',
28
+ SESSION_EXPIRED: '会话已过期,请重新登录',
29
+ SOCIAL_ACCOUNT_ALREADY_LINKED: '该社交账号已被其他用户绑定',
30
+ UNEXPECTED_ERROR: '发生未知错误,请重试',
31
+ UNKNOWN: '发生未知错误,请重试或联系支持',
32
+ USER_ALREADY_EXISTS: '用户已存在',
33
+ USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL: '邮箱已被使用,请尝试其他邮箱',
34
+ USER_ALREADY_HAS_PASSWORD: '该账号已设置密码',
35
+ USER_BANNED: '该用户已被封禁',
36
+ USER_EMAIL_NOT_FOUND: '未找到对应邮箱',
37
+ USER_NOT_FOUND: '未找到用户',
38
+ },
39
+ title: '身份验证出错',
40
+ };
@@ -1,4 +1,5 @@
1
1
  import auth from './auth';
2
+ import authError from './authError';
2
3
  import changelog from './changelog';
3
4
  import chat from './chat';
4
5
  import clerk from './clerk';
@@ -33,6 +34,7 @@ import welcome from './welcome';
33
34
 
34
35
  const resources = {
35
36
  auth,
37
+ authError,
36
38
  changelog,
37
39
  chat,
38
40
  clerk,