@lobehub/chat 1.70.8 → 1.70.10
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/CHANGELOG.md +42 -0
- package/changelog/v1.json +14 -0
- package/locales/ar/topic.json +1 -0
- package/locales/bg-BG/topic.json +1 -0
- package/locales/de-DE/topic.json +1 -0
- package/locales/en-US/topic.json +1 -0
- package/locales/es-ES/topic.json +1 -0
- package/locales/fa-IR/topic.json +1 -0
- package/locales/fr-FR/topic.json +1 -0
- package/locales/it-IT/topic.json +1 -0
- package/locales/ja-JP/topic.json +1 -0
- package/locales/ko-KR/topic.json +1 -0
- package/locales/nl-NL/topic.json +1 -0
- package/locales/pl-PL/topic.json +1 -0
- package/locales/pt-BR/topic.json +1 -0
- package/locales/ru-RU/topic.json +1 -0
- package/locales/tr-TR/topic.json +1 -0
- package/locales/vi-VN/topic.json +1 -0
- package/locales/zh-CN/topic.json +2 -1
- package/locales/zh-TW/topic.json +1 -0
- package/package.json +1 -1
- package/src/app/(backend)/middleware/auth/index.ts +5 -2
- package/src/app/[variants]/(main)/chat/(workspace)/@topic/features/SkeletonList.tsx +2 -2
- package/src/app/[variants]/(main)/chat/(workspace)/@topic/features/TopicListContent/SearchResult/index.tsx +59 -0
- package/src/app/[variants]/(main)/chat/(workspace)/@topic/features/TopicListContent/index.tsx +8 -3
- package/src/app/[variants]/(main)/chat/(workspace)/@topic/features/TopicSearchBar/index.tsx +23 -9
- package/src/database/schemas/user.ts +0 -3
- package/src/database/server/models/topic.ts +46 -16
- package/src/database/server/models/user.ts +2 -2
- package/src/features/AgentSetting/AgentMeta/index.tsx +12 -1
- package/src/libs/clerk-auth/index.test.ts +216 -0
- package/src/libs/clerk-auth/index.ts +80 -0
- package/src/locales/default/topic.ts +1 -0
- package/src/server/context.ts +7 -8
- package/src/server/routers/lambda/user.ts +3 -2
- package/src/store/chat/slices/topic/action.ts +5 -1
- package/src/store/chat/slices/topic/initialState.ts +1 -0
- package/src/store/chat/slices/topic/selectors.test.ts +4 -2
- package/src/store/chat/slices/topic/selectors.ts +7 -2
- package/src/utils/server/auth.ts +3 -5
package/CHANGELOG.md
CHANGED
@@ -2,6 +2,48 @@
|
|
2
2
|
|
3
3
|
# Changelog
|
4
4
|
|
5
|
+
### [Version 1.70.10](https://github.com/lobehub/lobe-chat/compare/v1.70.9...v1.70.10)
|
6
|
+
|
7
|
+
<sup>Released on **2025-03-12**</sup>
|
8
|
+
|
9
|
+
#### 🐛 Bug Fixes
|
10
|
+
|
11
|
+
- **misc**: The agent setting `-edit_agent` not work.
|
12
|
+
|
13
|
+
<br/>
|
14
|
+
|
15
|
+
<details>
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
17
|
+
|
18
|
+
#### What's fixed
|
19
|
+
|
20
|
+
- **misc**: The agent setting `-edit_agent` not work, closes [#4609](https://github.com/lobehub/lobe-chat/issues/4609) ([7af0ec6](https://github.com/lobehub/lobe-chat/commit/7af0ec6))
|
21
|
+
|
22
|
+
</details>
|
23
|
+
|
24
|
+
<div align="right">
|
25
|
+
|
26
|
+
[](#readme-top)
|
27
|
+
|
28
|
+
</div>
|
29
|
+
|
30
|
+
### [Version 1.70.9](https://github.com/lobehub/lobe-chat/compare/v1.70.8...v1.70.9)
|
31
|
+
|
32
|
+
<sup>Released on **2025-03-12**</sup>
|
33
|
+
|
34
|
+
<br/>
|
35
|
+
|
36
|
+
<details>
|
37
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
38
|
+
|
39
|
+
</details>
|
40
|
+
|
41
|
+
<div align="right">
|
42
|
+
|
43
|
+
[](#readme-top)
|
44
|
+
|
45
|
+
</div>
|
46
|
+
|
5
47
|
### [Version 1.70.8](https://github.com/lobehub/lobe-chat/compare/v1.70.7...v1.70.8)
|
6
48
|
|
7
49
|
<sup>Released on **2025-03-12**</sup>
|
package/changelog/v1.json
CHANGED
@@ -1,4 +1,18 @@
|
|
1
1
|
[
|
2
|
+
{
|
3
|
+
"children": {
|
4
|
+
"fixes": [
|
5
|
+
"The agent setting -edit_agent not work."
|
6
|
+
]
|
7
|
+
},
|
8
|
+
"date": "2025-03-12",
|
9
|
+
"version": "1.70.10"
|
10
|
+
},
|
11
|
+
{
|
12
|
+
"children": {},
|
13
|
+
"date": "2025-03-12",
|
14
|
+
"version": "1.70.9"
|
15
|
+
},
|
2
16
|
{
|
3
17
|
"children": {
|
4
18
|
"fixes": [
|
package/locales/ar/topic.json
CHANGED
package/locales/bg-BG/topic.json
CHANGED
package/locales/de-DE/topic.json
CHANGED
package/locales/en-US/topic.json
CHANGED
package/locales/es-ES/topic.json
CHANGED
package/locales/fa-IR/topic.json
CHANGED
package/locales/fr-FR/topic.json
CHANGED
package/locales/it-IT/topic.json
CHANGED
package/locales/ja-JP/topic.json
CHANGED
package/locales/ko-KR/topic.json
CHANGED
package/locales/nl-NL/topic.json
CHANGED
package/locales/pl-PL/topic.json
CHANGED
package/locales/pt-BR/topic.json
CHANGED
package/locales/ru-RU/topic.json
CHANGED
package/locales/tr-TR/topic.json
CHANGED
package/locales/vi-VN/topic.json
CHANGED
package/locales/zh-CN/topic.json
CHANGED
package/locales/zh-TW/topic.json
CHANGED
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@lobehub/chat",
|
3
|
-
"version": "1.70.
|
3
|
+
"version": "1.70.10",
|
4
4
|
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
5
5
|
"keywords": [
|
6
6
|
"framework",
|
@@ -1,9 +1,9 @@
|
|
1
1
|
import { AuthObject } from '@clerk/backend';
|
2
|
-
import { getAuth } from '@clerk/nextjs/server';
|
3
2
|
import { NextRequest } from 'next/server';
|
4
3
|
|
5
4
|
import { JWTPayload, LOBE_CHAT_AUTH_HEADER, OAUTH_AUTHORIZED, enableClerk } from '@/const/auth';
|
6
5
|
import { AgentRuntime, AgentRuntimeError, ChatCompletionErrorPayload } from '@/libs/agent-runtime';
|
6
|
+
import { ClerkAuth } from '@/libs/clerk-auth';
|
7
7
|
import { ChatErrorType } from '@/types/fetch';
|
8
8
|
import { createErrorResponse } from '@/utils/errorResponse';
|
9
9
|
import { getJWTPayload } from '@/utils/server/jwt';
|
@@ -41,8 +41,11 @@ export const checkAuth =
|
|
41
41
|
// check the Auth With payload and clerk auth
|
42
42
|
let clerkAuth = {} as AuthObject;
|
43
43
|
|
44
|
+
// TODO: V2 完整移除 client 模式下的 clerk 集成代码
|
44
45
|
if (enableClerk) {
|
45
|
-
|
46
|
+
const auth = new ClerkAuth();
|
47
|
+
const data = auth.getAuthFromRequest(req as NextRequest);
|
48
|
+
clerkAuth = data.clerkAuth;
|
46
49
|
}
|
47
50
|
|
48
51
|
jwtPayload = await getJWTPayload(authorization);
|
@@ -23,7 +23,7 @@ const useStyles = createStyles(({ css, prefixCls }) => ({
|
|
23
23
|
|
24
24
|
paragraph: css`
|
25
25
|
> li {
|
26
|
-
height:
|
26
|
+
height: 20px !important;
|
27
27
|
}
|
28
28
|
`,
|
29
29
|
}));
|
@@ -49,7 +49,7 @@ export const Placeholder = memo(() => {
|
|
49
49
|
|
50
50
|
export const SkeletonList = memo(() => (
|
51
51
|
<Flexbox style={{ paddingTop: 6 }}>
|
52
|
-
{Array.from({ length:
|
52
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
53
53
|
<Placeholder key={i} />
|
54
54
|
))}
|
55
55
|
</Flexbox>
|
@@ -0,0 +1,59 @@
|
|
1
|
+
'use client';
|
2
|
+
|
3
|
+
import { Typography } from 'antd';
|
4
|
+
import isEqual from 'fast-deep-equal';
|
5
|
+
import React, { memo, useCallback, useRef } from 'react';
|
6
|
+
import { useTranslation } from 'react-i18next';
|
7
|
+
import { Center } from 'react-layout-kit';
|
8
|
+
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
|
9
|
+
|
10
|
+
import { useChatStore } from '@/store/chat';
|
11
|
+
import { topicSelectors } from '@/store/chat/selectors';
|
12
|
+
import { ChatTopic } from '@/types/topic';
|
13
|
+
|
14
|
+
import { SkeletonList } from '../../SkeletonList';
|
15
|
+
import TopicItem from '../TopicItem';
|
16
|
+
|
17
|
+
const SearchResult = memo(() => {
|
18
|
+
const { t } = useTranslation('topic');
|
19
|
+
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
20
|
+
const [activeTopicId, isSearchingTopic] = useChatStore((s) => [
|
21
|
+
s.activeTopicId,
|
22
|
+
topicSelectors.isSearchingTopic(s),
|
23
|
+
]);
|
24
|
+
const topics = useChatStore(topicSelectors.searchTopics, isEqual);
|
25
|
+
|
26
|
+
const itemContent = useCallback(
|
27
|
+
(index: number, { id, favorite, title }: ChatTopic) => (
|
28
|
+
<TopicItem active={activeTopicId === id} fav={favorite} id={id} key={id} title={title} />
|
29
|
+
),
|
30
|
+
[activeTopicId],
|
31
|
+
);
|
32
|
+
|
33
|
+
const activeIndex = topics.findIndex((topic) => topic.id === activeTopicId);
|
34
|
+
|
35
|
+
if (isSearchingTopic) return <SkeletonList />;
|
36
|
+
|
37
|
+
if (topics.length === 0)
|
38
|
+
return (
|
39
|
+
<Center paddingBlock={12}>
|
40
|
+
<Typography.Text type={'secondary'}>{t('searchResultEmpty')}</Typography.Text>
|
41
|
+
</Center>
|
42
|
+
);
|
43
|
+
|
44
|
+
return (
|
45
|
+
<Virtuoso
|
46
|
+
computeItemKey={(_, item) => item.id}
|
47
|
+
data={topics}
|
48
|
+
defaultItemHeight={44}
|
49
|
+
initialTopMostItemIndex={Math.max(activeIndex, 0)}
|
50
|
+
itemContent={itemContent}
|
51
|
+
overscan={44 * 10}
|
52
|
+
ref={virtuosoRef}
|
53
|
+
/>
|
54
|
+
);
|
55
|
+
});
|
56
|
+
|
57
|
+
SearchResult.displayName = 'SearchResult';
|
58
|
+
|
59
|
+
export default SearchResult;
|
package/src/app/[variants]/(main)/chat/(workspace)/@topic/features/TopicListContent/index.tsx
CHANGED
@@ -2,7 +2,6 @@
|
|
2
2
|
|
3
3
|
import { EmptyCard } from '@lobehub/ui';
|
4
4
|
import { useThemeMode } from 'antd-style';
|
5
|
-
import isEqual from 'fast-deep-equal';
|
6
5
|
import React, { memo } from 'react';
|
7
6
|
import { useTranslation } from 'react-i18next';
|
8
7
|
import { Flexbox } from 'react-layout-kit';
|
@@ -18,6 +17,7 @@ import { TopicDisplayMode } from '@/types/topic';
|
|
18
17
|
import { SkeletonList } from '../SkeletonList';
|
19
18
|
import ByTimeMode from './ByTimeMode';
|
20
19
|
import FlatMode from './FlatMode';
|
20
|
+
import SearchResult from './SearchResult';
|
21
21
|
|
22
22
|
const TopicListContent = memo(() => {
|
23
23
|
const { t } = useTranslation('topic');
|
@@ -26,7 +26,10 @@ const TopicListContent = memo(() => {
|
|
26
26
|
s.topicsInit,
|
27
27
|
topicSelectors.currentTopicLength(s),
|
28
28
|
]);
|
29
|
-
const
|
29
|
+
const [isUndefinedTopics, isInSearchMode] = useChatStore((s) => [
|
30
|
+
topicSelectors.isUndefinedTopics(s),
|
31
|
+
topicSelectors.isInSearchMode(s),
|
32
|
+
]);
|
30
33
|
|
31
34
|
const [visible, updateGuideState, topicDisplayMode] = useUserStore((s) => [
|
32
35
|
s.preference.guide?.topic,
|
@@ -36,8 +39,10 @@ const TopicListContent = memo(() => {
|
|
36
39
|
|
37
40
|
useFetchTopics();
|
38
41
|
|
42
|
+
if (isInSearchMode) return <SearchResult />;
|
43
|
+
|
39
44
|
// first time loading or has no data
|
40
|
-
if (!topicsInit ||
|
45
|
+
if (!topicsInit || isUndefinedTopics) return <SkeletonList />;
|
41
46
|
|
42
47
|
return (
|
43
48
|
<>
|
@@ -11,30 +11,44 @@ import { useServerConfigStore } from '@/store/serverConfig';
|
|
11
11
|
const TopicSearchBar = memo<{ onClear?: () => void }>(({ onClear }) => {
|
12
12
|
const { t } = useTranslation('topic');
|
13
13
|
|
14
|
-
const [
|
14
|
+
const [tempValue, setTempValue] = useState('');
|
15
|
+
const [searchKeyword, setSearchKeywords] = useState('');
|
15
16
|
const mobile = useServerConfigStore((s) => s.isMobile);
|
16
17
|
const [activeSessionId, useSearchTopics] = useChatStore((s) => [s.activeId, s.useSearchTopics]);
|
17
18
|
|
18
|
-
useSearchTopics(
|
19
|
+
useSearchTopics(searchKeyword, activeSessionId);
|
20
|
+
|
19
21
|
useUnmount(() => {
|
20
|
-
useChatStore.setState({ isSearchingTopic: false });
|
22
|
+
useChatStore.setState({ inSearchingMode: false, isSearchingTopic: false });
|
21
23
|
});
|
24
|
+
|
25
|
+
const startSearchTopic = () => {
|
26
|
+
if (tempValue === searchKeyword) return;
|
27
|
+
|
28
|
+
setSearchKeywords(tempValue);
|
29
|
+
useChatStore.setState({ inSearchingMode: !!tempValue, isSearchingTopic: !!tempValue });
|
30
|
+
};
|
31
|
+
|
22
32
|
return (
|
23
33
|
<SearchBar
|
24
34
|
autoFocus
|
25
35
|
onBlur={() => {
|
26
|
-
if (
|
36
|
+
if (tempValue === '') {
|
37
|
+
onClear?.();
|
38
|
+
|
39
|
+
return;
|
40
|
+
}
|
41
|
+
|
42
|
+
startSearchTopic();
|
27
43
|
}}
|
28
44
|
onChange={(e) => {
|
29
|
-
|
30
|
-
|
31
|
-
setKeywords(value);
|
32
|
-
useChatStore.setState({ isSearchingTopic: !!value });
|
45
|
+
setTempValue(e.target.value);
|
33
46
|
}}
|
47
|
+
onPressEnter={startSearchTopic}
|
34
48
|
placeholder={t('searchPlaceholder')}
|
35
49
|
spotlight={!mobile}
|
36
50
|
type={mobile ? 'block' : 'ghost'}
|
37
|
-
value={
|
51
|
+
value={tempValue}
|
38
52
|
/>
|
39
53
|
);
|
40
54
|
});
|
@@ -1,5 +1,5 @@
|
|
1
|
-
import {
|
2
|
-
import { and, desc, eq,
|
1
|
+
import { count, sql } from 'drizzle-orm';
|
2
|
+
import { and, desc, eq, gt, ilike, inArray, isNull } from 'drizzle-orm/expressions';
|
3
3
|
|
4
4
|
import { LobeChatDatabase } from '@/database/type';
|
5
5
|
import {
|
@@ -79,27 +79,57 @@ export class TopicModel {
|
|
79
79
|
|
80
80
|
const keywordLowerCase = keyword.toLowerCase();
|
81
81
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
return this.db.query.topics.findMany({
|
82
|
+
// 查询标题匹配的主题
|
83
|
+
const topicsByTitle = await this.db.query.topics.findMany({
|
86
84
|
orderBy: [desc(topics.updatedAt)],
|
87
85
|
where: and(
|
88
86
|
eq(topics.userId, this.userId),
|
89
87
|
this.matchSession(sessionId),
|
90
|
-
|
91
|
-
matchKeyword(topics.title),
|
92
|
-
exists(
|
93
|
-
this.db
|
94
|
-
.select()
|
95
|
-
.from(messages)
|
96
|
-
.where(and(eq(messages.topicId, topics.id), matchKeyword(messages.content))),
|
97
|
-
),
|
98
|
-
),
|
88
|
+
ilike(topics.title, `%${keywordLowerCase}%`),
|
99
89
|
),
|
100
90
|
});
|
101
|
-
};
|
102
91
|
|
92
|
+
// 查询消息内容匹配的主题ID
|
93
|
+
const topicIdsByMessages = await this.db
|
94
|
+
.select({ topicId: messages.topicId })
|
95
|
+
.from(messages)
|
96
|
+
.innerJoin(topics, eq(messages.topicId, topics.id))
|
97
|
+
.where(
|
98
|
+
and(
|
99
|
+
eq(messages.userId, this.userId),
|
100
|
+
ilike(messages.content, `%${keywordLowerCase}%`),
|
101
|
+
eq(topics.userId, this.userId),
|
102
|
+
this.matchSession(sessionId),
|
103
|
+
),
|
104
|
+
)
|
105
|
+
.groupBy(messages.topicId);
|
106
|
+
// 如果没有通过消息内容找到主题,直接返回标题匹配的主题
|
107
|
+
if (topicIdsByMessages.length === 0) {
|
108
|
+
return topicsByTitle;
|
109
|
+
}
|
110
|
+
|
111
|
+
// 查询通过消息内容找到的主题
|
112
|
+
const topicIds = topicIdsByMessages.map((t) => t.topicId);
|
113
|
+
const topicsByMessages = await this.db.query.topics.findMany({
|
114
|
+
orderBy: [desc(topics.updatedAt)],
|
115
|
+
where: and(eq(topics.userId, this.userId), inArray(topics.id, topicIds)),
|
116
|
+
});
|
117
|
+
|
118
|
+
// 合并结果并去重
|
119
|
+
const allTopics = [...topicsByTitle];
|
120
|
+
const existingIds = new Set(topicsByTitle.map((t) => t.id));
|
121
|
+
|
122
|
+
for (const topic of topicsByMessages) {
|
123
|
+
if (!existingIds.has(topic.id)) {
|
124
|
+
allTopics.push(topic);
|
125
|
+
}
|
126
|
+
}
|
127
|
+
|
128
|
+
// 按更新时间排序
|
129
|
+
return allTopics.sort(
|
130
|
+
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
131
|
+
);
|
132
|
+
};
|
103
133
|
count = async (params?: {
|
104
134
|
endDate?: string;
|
105
135
|
range?: [string, string];
|
@@ -174,7 +174,7 @@ export class UserModel {
|
|
174
174
|
// if user already exists, skip creation
|
175
175
|
if (params.id) {
|
176
176
|
const user = await db.query.users.findFirst({ where: eq(users.id, params.id) });
|
177
|
-
if (!!user) return;
|
177
|
+
if (!!user) return { duplicate: true };
|
178
178
|
}
|
179
179
|
|
180
180
|
const [user] = await db
|
@@ -182,7 +182,7 @@ export class UserModel {
|
|
182
182
|
.values({ ...params })
|
183
183
|
.returning();
|
184
184
|
|
185
|
-
return user;
|
185
|
+
return { duplicate: false, user };
|
186
186
|
};
|
187
187
|
|
188
188
|
static deleteUser = async (db: LobeChatDatabase, id: string) => {
|
@@ -9,6 +9,7 @@ import { memo } from 'react';
|
|
9
9
|
import { useTranslation } from 'react-i18next';
|
10
10
|
|
11
11
|
import { FORM_STYLE } from '@/const/layoutTokens';
|
12
|
+
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
12
13
|
import { INBOX_SESSION_ID } from '@/const/session';
|
13
14
|
|
14
15
|
import { useStore } from '../store';
|
@@ -21,6 +22,8 @@ import BackgroundSwatches from './BackgroundSwatches';
|
|
21
22
|
const AgentMeta = memo(() => {
|
22
23
|
const { t } = useTranslation('setting');
|
23
24
|
|
25
|
+
const { isAgentEditable } = useServerConfigStore(featureFlagsSelectors);
|
26
|
+
|
24
27
|
const [hasSystemRole, updateMeta, autocompleteMeta, autocompleteAllMeta] = useStore((s) => [
|
25
28
|
!!s.config.systemRole,
|
26
29
|
s.setAgentMeta,
|
@@ -139,7 +142,15 @@ const AgentMeta = memo(() => {
|
|
139
142
|
title: t('settingAgent.title'),
|
140
143
|
};
|
141
144
|
|
142
|
-
return
|
145
|
+
return (
|
146
|
+
<Form
|
147
|
+
disabled={!isAgentEditable}
|
148
|
+
items={[metaData]}
|
149
|
+
itemsType={'group'}
|
150
|
+
variant={'pure'}
|
151
|
+
{...FORM_STYLE}
|
152
|
+
/>
|
153
|
+
);
|
143
154
|
});
|
144
155
|
|
145
156
|
export default AgentMeta;
|
@@ -0,0 +1,216 @@
|
|
1
|
+
import { auth, getAuth } from '@clerk/nextjs/server';
|
2
|
+
import { NextRequest } from 'next/server';
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
4
|
+
|
5
|
+
import { ClerkAuth } from './index';
|
6
|
+
|
7
|
+
// 模拟 @clerk/nextjs/server 模块
|
8
|
+
vi.mock('@clerk/nextjs/server', () => ({
|
9
|
+
auth: vi.fn(),
|
10
|
+
getAuth: vi.fn(),
|
11
|
+
}));
|
12
|
+
|
13
|
+
// 模拟 process.env
|
14
|
+
const originalEnv = { ...process.env };
|
15
|
+
|
16
|
+
beforeEach(() => {
|
17
|
+
// 重置所有模拟
|
18
|
+
vi.resetAllMocks();
|
19
|
+
|
20
|
+
// 重置环境变量
|
21
|
+
process.env = { ...originalEnv };
|
22
|
+
Object.assign(process.env, { NODE_ENV: 'development' });
|
23
|
+
});
|
24
|
+
|
25
|
+
afterEach(() => {
|
26
|
+
// 恢复环境变量
|
27
|
+
process.env = originalEnv;
|
28
|
+
});
|
29
|
+
|
30
|
+
describe('ClerkAuth', () => {
|
31
|
+
describe('constructor', () => {
|
32
|
+
it('should parse user ID mapping from environment variable', () => {
|
33
|
+
process.env.CLERK_DEV_IMPERSONATE_USER = 'dev_user=prod_user';
|
34
|
+
const clerkAuth = new ClerkAuth();
|
35
|
+
|
36
|
+
// 使用私有属性测试,需要使用类型断言
|
37
|
+
expect(clerkAuth['devUserId']).toBe('dev_user');
|
38
|
+
expect(clerkAuth['prodUserId']).toBe('prod_user');
|
39
|
+
});
|
40
|
+
|
41
|
+
it('should handle empty mapping string', () => {
|
42
|
+
process.env.CLERK_DEV_IMPERSONATE_USER = '';
|
43
|
+
const clerkAuth = new ClerkAuth();
|
44
|
+
|
45
|
+
expect((clerkAuth as any).devUserId).toBeNull();
|
46
|
+
expect((clerkAuth as any).prodUserId).toBeNull();
|
47
|
+
});
|
48
|
+
|
49
|
+
it('should handle invalid mapping format', () => {
|
50
|
+
process.env.CLERK_DEV_IMPERSONATE_USER = 'invalid_format';
|
51
|
+
const clerkAuth = new ClerkAuth();
|
52
|
+
|
53
|
+
expect((clerkAuth as any).devUserId).toBeNull();
|
54
|
+
expect((clerkAuth as any).prodUserId).toBeNull();
|
55
|
+
});
|
56
|
+
|
57
|
+
it('should handle undefined mapping', () => {
|
58
|
+
delete process.env.CLERK_DEV_IMPERSONATE_USER;
|
59
|
+
const clerkAuth = new ClerkAuth();
|
60
|
+
|
61
|
+
expect((clerkAuth as any).devUserId).toBeNull();
|
62
|
+
expect((clerkAuth as any).prodUserId).toBeNull();
|
63
|
+
});
|
64
|
+
});
|
65
|
+
|
66
|
+
describe('getAuthFromRequest', () => {
|
67
|
+
it('should get auth from request and return original user ID when no mapping', () => {
|
68
|
+
// 设置模拟返回值
|
69
|
+
vi.mocked(getAuth).mockReturnValue({ userId: 'original_user_id' } as any);
|
70
|
+
|
71
|
+
const clerkAuth = new ClerkAuth();
|
72
|
+
const mockRequest = {} as NextRequest;
|
73
|
+
const result = clerkAuth.getAuthFromRequest(mockRequest);
|
74
|
+
|
75
|
+
expect(getAuth).toHaveBeenCalledWith(mockRequest);
|
76
|
+
expect(result).toEqual({
|
77
|
+
clerkAuth: { userId: 'original_user_id' },
|
78
|
+
userId: 'original_user_id',
|
79
|
+
});
|
80
|
+
});
|
81
|
+
|
82
|
+
it('should map user ID in development environment', () => {
|
83
|
+
// 设置环境和模拟
|
84
|
+
process.env.CLERK_DEV_IMPERSONATE_USER = 'dev_user=prod_user';
|
85
|
+
Object.assign(process.env, { NODE_ENV: 'development' });
|
86
|
+
vi.mocked(getAuth).mockReturnValue({ userId: 'dev_user' } as any);
|
87
|
+
|
88
|
+
const clerkAuth = new ClerkAuth();
|
89
|
+
const result = clerkAuth.getAuthFromRequest({} as NextRequest);
|
90
|
+
|
91
|
+
expect(result).toEqual({
|
92
|
+
clerkAuth: { userId: 'dev_user' },
|
93
|
+
userId: 'prod_user',
|
94
|
+
});
|
95
|
+
});
|
96
|
+
|
97
|
+
it('should not map user ID in production environment', () => {
|
98
|
+
// 设置环境和模拟
|
99
|
+
process.env.CLERK_DEV_IMPERSONATE_USER = 'dev_user=prod_user';
|
100
|
+
Object.assign(process.env, { NODE_ENV: 'production' });
|
101
|
+
|
102
|
+
vi.mocked(getAuth).mockReturnValue({ userId: 'dev_user' } as any);
|
103
|
+
|
104
|
+
const clerkAuth = new ClerkAuth();
|
105
|
+
const result = clerkAuth.getAuthFromRequest({} as NextRequest);
|
106
|
+
|
107
|
+
expect(result).toEqual({
|
108
|
+
clerkAuth: { userId: 'dev_user' },
|
109
|
+
userId: 'dev_user',
|
110
|
+
});
|
111
|
+
});
|
112
|
+
|
113
|
+
it('should handle null user ID', () => {
|
114
|
+
vi.mocked(getAuth).mockReturnValue({ userId: null } as any);
|
115
|
+
|
116
|
+
const clerkAuth = new ClerkAuth();
|
117
|
+
const result = clerkAuth.getAuthFromRequest({} as NextRequest);
|
118
|
+
|
119
|
+
expect(result).toEqual({
|
120
|
+
clerkAuth: { userId: null },
|
121
|
+
userId: null,
|
122
|
+
});
|
123
|
+
});
|
124
|
+
});
|
125
|
+
|
126
|
+
describe('getAuth', () => {
|
127
|
+
it('should get auth and return original user ID when no mapping', async () => {
|
128
|
+
vi.mocked(auth).mockResolvedValue({ userId: 'original_user_id' } as any);
|
129
|
+
|
130
|
+
const clerkAuth = new ClerkAuth();
|
131
|
+
const result = await clerkAuth.getAuth();
|
132
|
+
|
133
|
+
expect(auth).toHaveBeenCalled();
|
134
|
+
expect(result).toEqual({
|
135
|
+
clerkAuth: { userId: 'original_user_id' },
|
136
|
+
userId: 'original_user_id',
|
137
|
+
});
|
138
|
+
});
|
139
|
+
|
140
|
+
it('should map user ID in development environment', async () => {
|
141
|
+
process.env.CLERK_DEV_IMPERSONATE_USER = 'dev_user=prod_user';
|
142
|
+
Object.assign(process.env, { NODE_ENV: 'development' });
|
143
|
+
vi.mocked(auth).mockResolvedValue({ userId: 'dev_user' } as any);
|
144
|
+
|
145
|
+
const clerkAuth = new ClerkAuth();
|
146
|
+
const result = await clerkAuth.getAuth();
|
147
|
+
|
148
|
+
expect(result).toEqual({
|
149
|
+
clerkAuth: { userId: 'dev_user' },
|
150
|
+
userId: 'prod_user',
|
151
|
+
});
|
152
|
+
});
|
153
|
+
|
154
|
+
it('should not map user ID in production environment', async () => {
|
155
|
+
process.env.CLERK_DEV_IMPERSONATE_USER = 'dev_user=prod_user';
|
156
|
+
Object.assign(process.env, { NODE_ENV: 'production' });
|
157
|
+
vi.mocked(auth).mockResolvedValue({ userId: 'dev_user' } as any);
|
158
|
+
|
159
|
+
const clerkAuth = new ClerkAuth();
|
160
|
+
const result = await clerkAuth.getAuth();
|
161
|
+
|
162
|
+
expect(result).toEqual({
|
163
|
+
clerkAuth: { userId: 'dev_user' },
|
164
|
+
userId: 'dev_user',
|
165
|
+
});
|
166
|
+
});
|
167
|
+
|
168
|
+
it('should handle null user ID', async () => {
|
169
|
+
vi.mocked(auth).mockResolvedValue({ userId: null } as any);
|
170
|
+
|
171
|
+
const clerkAuth = new ClerkAuth();
|
172
|
+
const result = await clerkAuth.getAuth();
|
173
|
+
|
174
|
+
expect(result).toEqual({
|
175
|
+
clerkAuth: { userId: null },
|
176
|
+
userId: null,
|
177
|
+
});
|
178
|
+
});
|
179
|
+
});
|
180
|
+
|
181
|
+
describe('getMappedUserId', () => {
|
182
|
+
it('should return null for null input', () => {
|
183
|
+
const clerkAuth = new ClerkAuth();
|
184
|
+
const result = (clerkAuth as any).getMappedUserId(null);
|
185
|
+
|
186
|
+
expect(result).toBeNull();
|
187
|
+
});
|
188
|
+
|
189
|
+
it('should return original ID when no mapping exists', () => {
|
190
|
+
const clerkAuth = new ClerkAuth();
|
191
|
+
const result = (clerkAuth as any).getMappedUserId('some_user_id');
|
192
|
+
|
193
|
+
expect(result).toBe('some_user_id');
|
194
|
+
});
|
195
|
+
|
196
|
+
it('should return mapped ID when matching dev ID in development', () => {
|
197
|
+
process.env.CLERK_DEV_IMPERSONATE_USER = 'dev_user=prod_user';
|
198
|
+
Object.assign(process.env, { NODE_ENV: 'development' });
|
199
|
+
|
200
|
+
const clerkAuth = new ClerkAuth();
|
201
|
+
const result = (clerkAuth as any).getMappedUserId('dev_user');
|
202
|
+
|
203
|
+
expect(result).toBe('prod_user');
|
204
|
+
});
|
205
|
+
|
206
|
+
it('should return original ID when not matching dev ID', () => {
|
207
|
+
process.env.CLERK_DEV_IMPERSONATE_USER = 'dev_user=prod_user';
|
208
|
+
Object.assign(process.env, { NODE_ENV: 'development' });
|
209
|
+
|
210
|
+
const clerkAuth = new ClerkAuth();
|
211
|
+
const result = (clerkAuth as any).getMappedUserId('other_user');
|
212
|
+
|
213
|
+
expect(result).toBe('other_user');
|
214
|
+
});
|
215
|
+
});
|
216
|
+
});
|
@@ -0,0 +1,80 @@
|
|
1
|
+
import { auth, currentUser, getAuth } from '@clerk/nextjs/server';
|
2
|
+
import type { NextRequest } from 'next/server';
|
3
|
+
|
4
|
+
export class ClerkAuth {
|
5
|
+
private devUserId: string | null = null;
|
6
|
+
private prodUserId: string | null = null;
|
7
|
+
|
8
|
+
constructor() {
|
9
|
+
this.parseUserIdMapping();
|
10
|
+
}
|
11
|
+
|
12
|
+
/**
|
13
|
+
* 从请求中获取认证信息和用户ID
|
14
|
+
*/
|
15
|
+
getAuthFromRequest(request: NextRequest) {
|
16
|
+
const clerkAuth = getAuth(request);
|
17
|
+
const userId = this.getMappedUserId(clerkAuth.userId);
|
18
|
+
|
19
|
+
return { clerkAuth, userId };
|
20
|
+
}
|
21
|
+
|
22
|
+
/**
|
23
|
+
* 获取当前认证信息和用户ID
|
24
|
+
*/
|
25
|
+
async getAuth() {
|
26
|
+
const clerkAuth = await auth();
|
27
|
+
const userId = this.getMappedUserId(clerkAuth.userId);
|
28
|
+
|
29
|
+
return { clerkAuth, userId };
|
30
|
+
}
|
31
|
+
|
32
|
+
async getCurrentUser() {
|
33
|
+
const user = await currentUser();
|
34
|
+
|
35
|
+
if (!user) return null;
|
36
|
+
|
37
|
+
const userId = this.getMappedUserId(user.id) as string;
|
38
|
+
|
39
|
+
return { ...user, id: userId };
|
40
|
+
}
|
41
|
+
|
42
|
+
/**
|
43
|
+
* 根据环境变量映射用户ID
|
44
|
+
*/
|
45
|
+
private getMappedUserId(originalUserId: string | null): string | null {
|
46
|
+
if (!originalUserId) return null;
|
47
|
+
|
48
|
+
// 只在开发环境下执行映射
|
49
|
+
if (
|
50
|
+
process.env.NODE_ENV === 'development' &&
|
51
|
+
this.devUserId &&
|
52
|
+
this.prodUserId &&
|
53
|
+
originalUserId === this.devUserId
|
54
|
+
) {
|
55
|
+
return this.prodUserId;
|
56
|
+
}
|
57
|
+
|
58
|
+
return originalUserId;
|
59
|
+
}
|
60
|
+
|
61
|
+
/**
|
62
|
+
* 解析环境变量中的用户ID映射配置
|
63
|
+
* 格式: "dev=prod"
|
64
|
+
*/
|
65
|
+
private parseUserIdMapping(): void {
|
66
|
+
const mappingStr = process.env.CLERK_DEV_IMPERSONATE_USER || '';
|
67
|
+
|
68
|
+
if (!mappingStr) return;
|
69
|
+
|
70
|
+
const [dev, prod] = mappingStr.split('=');
|
71
|
+
if (dev && prod) {
|
72
|
+
this.devUserId = dev.trim();
|
73
|
+
this.prodUserId = prod.trim();
|
74
|
+
}
|
75
|
+
}
|
76
|
+
}
|
77
|
+
|
78
|
+
export type IClerkAuth = ReturnType<typeof getAuth>;
|
79
|
+
|
80
|
+
export const clerkAuth = new ClerkAuth();
|
package/src/server/context.ts
CHANGED
@@ -1,15 +1,12 @@
|
|
1
|
-
/* eslint-disable @typescript-eslint/no-unused-vars */
|
2
|
-
import { getAuth } from '@clerk/nextjs/server';
|
3
1
|
import { User } from 'next-auth';
|
4
2
|
import { NextRequest } from 'next/server';
|
5
3
|
|
6
4
|
import { JWTPayload, LOBE_CHAT_AUTH_HEADER, enableClerk, enableNextAuth } from '@/const/auth';
|
7
|
-
|
8
|
-
type ClerkAuth = ReturnType<typeof getAuth>;
|
5
|
+
import { ClerkAuth, IClerkAuth } from '@/libs/clerk-auth';
|
9
6
|
|
10
7
|
export interface AuthContext {
|
11
8
|
authorizationHeader?: string | null;
|
12
|
-
clerkAuth?:
|
9
|
+
clerkAuth?: IClerkAuth;
|
13
10
|
jwtPayload?: JWTPayload | null;
|
14
11
|
nextAuth?: User;
|
15
12
|
userId?: string | null;
|
@@ -21,7 +18,7 @@ export interface AuthContext {
|
|
21
18
|
*/
|
22
19
|
export const createContextInner = async (params?: {
|
23
20
|
authorizationHeader?: string | null;
|
24
|
-
clerkAuth?:
|
21
|
+
clerkAuth?: IClerkAuth;
|
25
22
|
nextAuth?: User;
|
26
23
|
userId?: string | null;
|
27
24
|
}): Promise<AuthContext> => ({
|
@@ -46,9 +43,11 @@ export const createContext = async (request: NextRequest): Promise<Context> => {
|
|
46
43
|
let auth;
|
47
44
|
|
48
45
|
if (enableClerk) {
|
49
|
-
|
46
|
+
const clerkAuth = new ClerkAuth();
|
47
|
+
const result = clerkAuth.getAuthFromRequest(request);
|
48
|
+
auth = result.clerkAuth;
|
49
|
+
userId = result.userId;
|
50
50
|
|
51
|
-
userId = auth.userId;
|
52
51
|
return createContextInner({ authorizationHeader: authorization, clerkAuth: auth, userId });
|
53
52
|
}
|
54
53
|
|
@@ -1,5 +1,4 @@
|
|
1
1
|
import { UserJSON } from '@clerk/backend';
|
2
|
-
import { currentUser } from '@clerk/nextjs/server';
|
3
2
|
import { z } from 'zod';
|
4
3
|
|
5
4
|
import { enableClerk } from '@/const/auth';
|
@@ -7,6 +6,7 @@ import { serverDB } from '@/database/server';
|
|
7
6
|
import { MessageModel } from '@/database/server/models/message';
|
8
7
|
import { SessionModel } from '@/database/server/models/session';
|
9
8
|
import { UserModel, UserNotFoundError } from '@/database/server/models/user';
|
9
|
+
import { ClerkAuth } from '@/libs/clerk-auth';
|
10
10
|
import { LobeNextAuthDbAdapter } from '@/libs/next-auth/adapter';
|
11
11
|
import { authedProcedure, router } from '@/libs/trpc';
|
12
12
|
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
|
@@ -22,6 +22,7 @@ import { UserSettings } from '@/types/user/settings';
|
|
22
22
|
const userProcedure = authedProcedure.use(async (opts) => {
|
23
23
|
return opts.next({
|
24
24
|
ctx: {
|
25
|
+
clerkAuth: new ClerkAuth(),
|
25
26
|
nextAuthDbAdapter: LobeNextAuthDbAdapter(serverDB),
|
26
27
|
userModel: new UserModel(serverDB, opts.ctx.userId),
|
27
28
|
},
|
@@ -46,7 +47,7 @@ export const userRouter = router({
|
|
46
47
|
state = await ctx.userModel.getUserState(KeyVaultsGateKeeper.getUserKeyVaults);
|
47
48
|
} catch (error) {
|
48
49
|
if (enableClerk && error instanceof UserNotFoundError) {
|
49
|
-
const user = await
|
50
|
+
const user = await ctx.clerkAuth.getCurrentUser();
|
50
51
|
if (user) {
|
51
52
|
const userService = new UserService();
|
52
53
|
|
@@ -218,7 +218,11 @@ export const chatTopic: StateCreator<
|
|
218
218
|
topicService.searchTopics(keywords, sessionId),
|
219
219
|
{
|
220
220
|
onSuccess: (data) => {
|
221
|
-
set(
|
221
|
+
set(
|
222
|
+
{ searchTopics: data, isSearchingTopic: false },
|
223
|
+
false,
|
224
|
+
n('useSearchTopics(success)', { keywords }),
|
225
|
+
);
|
222
226
|
},
|
223
227
|
},
|
224
228
|
),
|
@@ -76,11 +76,13 @@ describe('topicSelectors', () => {
|
|
76
76
|
const topics = topicSelectors.displayTopics(state);
|
77
77
|
expect(topics).toEqual(topicMaps.test);
|
78
78
|
});
|
79
|
+
});
|
79
80
|
|
81
|
+
describe('searchTopics', () => {
|
80
82
|
it('should return search topics if searching', () => {
|
81
83
|
const searchTopics = [{ id: 'search1', name: 'Search 1' }];
|
82
|
-
const state = merge(initialStore, {
|
83
|
-
const topics = topicSelectors.
|
84
|
+
const state = merge(initialStore, { inSearchingMode: true, searchTopics });
|
85
|
+
const topics = topicSelectors.searchTopics(state);
|
84
86
|
expect(topics).toEqual(searchTopics);
|
85
87
|
});
|
86
88
|
});
|
@@ -12,8 +12,7 @@ const currentActiveTopic = (s: ChatStoreState): ChatTopic | undefined => {
|
|
12
12
|
};
|
13
13
|
const searchTopics = (s: ChatStoreState): ChatTopic[] => s.searchTopics;
|
14
14
|
|
15
|
-
const displayTopics = (s: ChatStoreState): ChatTopic[] | undefined =>
|
16
|
-
s.isSearchingTopic ? searchTopics(s) : currentTopics(s);
|
15
|
+
const displayTopics = (s: ChatStoreState): ChatTopic[] | undefined => currentTopics(s);
|
17
16
|
|
18
17
|
const currentFavTopics = (s: ChatStoreState): ChatTopic[] =>
|
19
18
|
currentTopics(s)?.filter((s) => s.favorite) || [];
|
@@ -40,6 +39,9 @@ const currentActiveTopicSummary = (s: ChatStoreState): ChatTopicSummary | undefi
|
|
40
39
|
};
|
41
40
|
|
42
41
|
const isCreatingTopic = (s: ChatStoreState) => s.creatingTopic;
|
42
|
+
const isUndefinedTopics = (s: ChatStoreState) => !currentTopics(s);
|
43
|
+
const isInSearchMode = (s: ChatStoreState) => s.inSearchingMode;
|
44
|
+
const isSearchingTopic = (s: ChatStoreState) => s.isSearchingTopic;
|
43
45
|
|
44
46
|
const groupedTopicsSelector = (s: ChatStoreState): GroupedTopic[] => {
|
45
47
|
const topics = displayTopics(s);
|
@@ -70,5 +72,8 @@ export const topicSelectors = {
|
|
70
72
|
getTopicById,
|
71
73
|
groupedTopicsSelector,
|
72
74
|
isCreatingTopic,
|
75
|
+
isInSearchMode,
|
76
|
+
isSearchingTopic,
|
77
|
+
isUndefinedTopics,
|
73
78
|
searchTopics,
|
74
79
|
};
|
package/src/utils/server/auth.ts
CHANGED
@@ -1,14 +1,12 @@
|
|
1
|
-
import { auth } from '@clerk/nextjs/server';
|
2
|
-
|
3
1
|
import { enableClerk, enableNextAuth } from '@/const/auth';
|
2
|
+
import { ClerkAuth } from '@/libs/clerk-auth';
|
4
3
|
import NextAuthEdge from '@/libs/next-auth/edge';
|
5
4
|
|
6
5
|
export const getUserAuth = async () => {
|
7
6
|
if (enableClerk) {
|
8
|
-
const clerkAuth =
|
7
|
+
const clerkAuth = new ClerkAuth();
|
9
8
|
|
10
|
-
|
11
|
-
return { clerkAuth: auth, userId };
|
9
|
+
return await clerkAuth.getAuth();
|
12
10
|
}
|
13
11
|
|
14
12
|
if (enableNextAuth) {
|