@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.
- package/.env.example +10 -0
- package/CHANGELOG.md +42 -0
- package/changelog/v1.json +14 -0
- package/e2e/src/steps/hooks.ts +1 -0
- package/locales/ar/authError.json +40 -0
- package/locales/ar/setting.json +25 -0
- package/locales/bg-BG/authError.json +40 -0
- package/locales/bg-BG/setting.json +25 -0
- package/locales/de-DE/authError.json +40 -0
- package/locales/de-DE/setting.json +25 -0
- package/locales/en-US/authError.json +40 -0
- package/locales/en-US/setting.json +25 -0
- package/locales/es-ES/authError.json +40 -0
- package/locales/es-ES/setting.json +25 -0
- package/locales/fa-IR/authError.json +40 -0
- package/locales/fa-IR/setting.json +25 -0
- package/locales/fr-FR/authError.json +40 -0
- package/locales/fr-FR/setting.json +25 -0
- package/locales/it-IT/authError.json +40 -0
- package/locales/it-IT/setting.json +25 -0
- package/locales/ja-JP/authError.json +40 -0
- package/locales/ja-JP/setting.json +25 -0
- package/locales/ko-KR/authError.json +40 -0
- package/locales/ko-KR/setting.json +25 -0
- package/locales/nl-NL/authError.json +40 -0
- package/locales/nl-NL/setting.json +25 -0
- package/locales/pl-PL/authError.json +40 -0
- package/locales/pl-PL/setting.json +25 -0
- package/locales/pt-BR/authError.json +40 -0
- package/locales/pt-BR/setting.json +25 -0
- package/locales/ru-RU/authError.json +40 -0
- package/locales/ru-RU/setting.json +25 -0
- package/locales/tr-TR/authError.json +40 -0
- package/locales/tr-TR/setting.json +25 -0
- package/locales/vi-VN/authError.json +40 -0
- package/locales/vi-VN/setting.json +25 -0
- package/locales/zh-CN/authError.json +40 -0
- package/locales/zh-CN/setting.json +25 -0
- package/locales/zh-TW/authError.json +40 -0
- package/locales/zh-TW/setting.json +25 -0
- package/next.config.ts +13 -1
- package/package.json +3 -1
- package/packages/const/src/index.ts +1 -0
- package/packages/const/src/klavis.ts +163 -0
- package/packages/database/migrations/meta/_journal.json +1 -1
- package/packages/database/src/core/migrations.json +1 -1
- package/packages/database/src/models/plugin.ts +1 -1
- package/packages/types/src/message/common/tools.ts +9 -0
- package/packages/types/src/serverConfig.ts +1 -0
- package/packages/types/src/tool/plugin.ts +10 -0
- package/src/app/[variants]/(auth)/auth-error/page.tsx +59 -0
- package/src/auth.ts +13 -48
- package/src/config/klavis.ts +41 -0
- package/src/envs/redis.ts +1 -1
- package/src/features/ChatInput/ActionBar/Tools/KlavisServerItem.tsx +351 -0
- package/src/features/ChatInput/ActionBar/Tools/index.tsx +56 -4
- package/src/features/ChatInput/ActionBar/Tools/useControls.tsx +174 -6
- package/src/features/ChatInput/ActionBar/components/ActionDropdown.tsx +3 -1
- package/src/helpers/toolEngineering/index.test.ts +3 -0
- package/src/helpers/toolEngineering/index.ts +13 -2
- package/src/libs/better-auth/utils/config.ts +91 -0
- package/src/libs/klavis/index.ts +36 -0
- package/src/libs/redis/manager.ts +5 -1
- package/src/libs/redis/redis.test.ts +1 -1
- package/src/libs/redis/upstash.test.ts +9 -5
- package/src/libs/redis/upstash.ts +44 -20
- package/src/locales/default/authError.ts +40 -0
- package/src/locales/default/index.ts +2 -0
- package/src/locales/default/setting.ts +25 -0
- package/src/proxy.ts +1 -0
- package/src/server/globalConfig/index.ts +2 -0
- package/src/server/routers/lambda/index.ts +2 -0
- package/src/server/routers/lambda/klavis.ts +249 -0
- package/src/server/routers/tools/index.ts +2 -0
- package/src/server/routers/tools/klavis.ts +80 -0
- package/src/server/services/mcp/index.ts +61 -15
- package/src/services/import/index.test.ts +658 -0
- package/src/services/mcp.test.ts +1 -1
- package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +2 -3
- package/src/store/chat/slices/plugin/action.test.ts +0 -1
- package/src/store/chat/slices/plugin/actions/internals.ts +22 -2
- package/src/store/chat/slices/plugin/actions/pluginTypes.ts +108 -0
- package/src/store/serverConfig/index.ts +1 -1
- package/src/store/serverConfig/selectors.ts +1 -0
- package/src/store/tool/initialState.ts +4 -1
- package/src/store/tool/selectors/index.ts +1 -0
- package/src/store/tool/slices/builtin/selectors.ts +25 -3
- package/src/store/tool/slices/klavisStore/action.test.ts +512 -0
- package/src/store/tool/slices/klavisStore/action.ts +375 -0
- package/src/store/tool/slices/klavisStore/index.ts +4 -0
- package/src/store/tool/slices/klavisStore/initialState.ts +25 -0
- package/src/store/tool/slices/klavisStore/selectors.test.ts +371 -0
- package/src/store/tool/slices/klavisStore/selectors.ts +123 -0
- package/src/store/tool/slices/klavisStore/types.ts +100 -0
- package/src/store/tool/slices/plugin/selectors.ts +16 -13
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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',
|
|
@@ -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 = [
|
|
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({
|
|
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
|
}
|
|
@@ -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', {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(...
|
|
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(...
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(...
|
|
97
|
+
return this.client.mget(...this.buildKeys(keys));
|
|
83
98
|
}
|
|
84
99
|
|
|
85
100
|
async mset(values: RedisMSetArgument): Promise<'OK'> {
|
|
86
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|