@lobehub/chat 1.70.7 → 1.70.9

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 (40) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/changelog/v1.json +14 -0
  3. package/locales/ar/topic.json +1 -0
  4. package/locales/bg-BG/topic.json +1 -0
  5. package/locales/de-DE/topic.json +1 -0
  6. package/locales/en-US/topic.json +1 -0
  7. package/locales/es-ES/topic.json +1 -0
  8. package/locales/fa-IR/topic.json +1 -0
  9. package/locales/fr-FR/topic.json +1 -0
  10. package/locales/it-IT/topic.json +1 -0
  11. package/locales/ja-JP/topic.json +1 -0
  12. package/locales/ko-KR/topic.json +1 -0
  13. package/locales/nl-NL/topic.json +1 -0
  14. package/locales/pl-PL/topic.json +1 -0
  15. package/locales/pt-BR/topic.json +1 -0
  16. package/locales/ru-RU/topic.json +1 -0
  17. package/locales/tr-TR/topic.json +1 -0
  18. package/locales/vi-VN/topic.json +1 -0
  19. package/locales/zh-CN/topic.json +2 -1
  20. package/locales/zh-TW/topic.json +1 -0
  21. package/package.json +1 -1
  22. package/src/app/(backend)/middleware/auth/index.ts +5 -2
  23. package/src/app/[variants]/(main)/chat/(workspace)/@topic/features/SkeletonList.tsx +2 -2
  24. package/src/app/[variants]/(main)/chat/(workspace)/@topic/features/TopicListContent/SearchResult/index.tsx +59 -0
  25. package/src/app/[variants]/(main)/chat/(workspace)/@topic/features/TopicListContent/index.tsx +8 -3
  26. package/src/app/[variants]/(main)/chat/(workspace)/@topic/features/TopicSearchBar/index.tsx +23 -9
  27. package/src/database/schemas/user.ts +0 -3
  28. package/src/database/server/models/topic.ts +46 -16
  29. package/src/database/server/models/user.ts +2 -2
  30. package/src/layout/GlobalProvider/AppTheme.tsx +10 -1
  31. package/src/libs/clerk-auth/index.test.ts +216 -0
  32. package/src/libs/clerk-auth/index.ts +80 -0
  33. package/src/locales/default/topic.ts +1 -0
  34. package/src/server/context.ts +7 -8
  35. package/src/server/routers/lambda/user.ts +3 -2
  36. package/src/store/chat/slices/topic/action.ts +5 -1
  37. package/src/store/chat/slices/topic/initialState.ts +1 -0
  38. package/src/store/chat/slices/topic/selectors.test.ts +4 -2
  39. package/src/store/chat/slices/topic/selectors.ts +7 -2
  40. 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.9](https://github.com/lobehub/lobe-chat/compare/v1.70.8...v1.70.9)
6
+
7
+ <sup>Released on **2025-03-12**</sup>
8
+
9
+ <br/>
10
+
11
+ <details>
12
+ <summary><kbd>Improvements and Fixes</kbd></summary>
13
+
14
+ </details>
15
+
16
+ <div align="right">
17
+
18
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
19
+
20
+ </div>
21
+
22
+ ### [Version 1.70.8](https://github.com/lobehub/lobe-chat/compare/v1.70.7...v1.70.8)
23
+
24
+ <sup>Released on **2025-03-12**</sup>
25
+
26
+ #### 🐛 Bug Fixes
27
+
28
+ - **misc**: Fix theme flicking.
29
+
30
+ <br/>
31
+
32
+ <details>
33
+ <summary><kbd>Improvements and Fixes</kbd></summary>
34
+
35
+ #### What's fixed
36
+
37
+ - **misc**: Fix theme flicking, closes [#6926](https://github.com/lobehub/lobe-chat/issues/6926) ([103c3e3](https://github.com/lobehub/lobe-chat/commit/103c3e3))
38
+
39
+ </details>
40
+
41
+ <div align="right">
42
+
43
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
44
+
45
+ </div>
46
+
5
47
  ### [Version 1.70.7](https://github.com/lobehub/lobe-chat/compare/v1.70.6...v1.70.7)
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
+ "date": "2025-03-12",
5
+ "version": "1.70.9"
6
+ },
7
+ {
8
+ "children": {
9
+ "fixes": [
10
+ "Fix theme flicking."
11
+ ]
12
+ },
13
+ "date": "2025-03-12",
14
+ "version": "1.70.8"
15
+ },
2
16
  {
3
17
  "children": {
4
18
  "fixes": [
@@ -32,6 +32,7 @@
32
32
  "title": "قائمة المواضيع"
33
33
  },
34
34
  "searchPlaceholder": "ابحث عن موضوع...",
35
+ "searchResultEmpty": "لا توجد نتائج للبحث",
35
36
  "temp": "مؤقت",
36
37
  "title": "موضوع"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "Списък с теми"
33
33
  },
34
34
  "searchPlaceholder": "Търсене на теми...",
35
+ "searchResultEmpty": "Няма намерени резултати",
35
36
  "temp": "Временен",
36
37
  "title": "Тема"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "Themenliste"
33
33
  },
34
34
  "searchPlaceholder": "Themen suchen...",
35
+ "searchResultEmpty": "Keine Suchergebnisse vorhanden",
35
36
  "temp": "Vorübergehend",
36
37
  "title": "Thema"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "Topic List"
33
33
  },
34
34
  "searchPlaceholder": "Search Topics...",
35
+ "searchResultEmpty": "No search results found.",
35
36
  "temp": "Temporary",
36
37
  "title": "Topic"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "Lista de temas"
33
33
  },
34
34
  "searchPlaceholder": "Buscar temas...",
35
+ "searchResultEmpty": "No hay resultados de búsqueda disponibles",
35
36
  "temp": "Temporal",
36
37
  "title": "Tema"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "لیست موضوعات"
33
33
  },
34
34
  "searchPlaceholder": "جستجوی موضوع...",
35
+ "searchResultEmpty": "نتیجه‌ای برای جستجو یافت نشد",
35
36
  "temp": "موقت",
36
37
  "title": "موضوع"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "Liste des sujets"
33
33
  },
34
34
  "searchPlaceholder": "Rechercher des sujets...",
35
+ "searchResultEmpty": "Aucun résultat de recherche disponible",
35
36
  "temp": "Temporaire",
36
37
  "title": "Sujet"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "Elenco dei temi"
33
33
  },
34
34
  "searchPlaceholder": "Cerca temi...",
35
+ "searchResultEmpty": "Nessun risultato trovato",
35
36
  "temp": "Temporaneo",
36
37
  "title": "Tema"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "トピックリスト"
33
33
  },
34
34
  "searchPlaceholder": "トピックを検索...",
35
+ "searchResultEmpty": "検索結果はありません",
35
36
  "temp": "一時的",
36
37
  "title": "トピック"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "주제 목록"
33
33
  },
34
34
  "searchPlaceholder": "주제 검색...",
35
+ "searchResultEmpty": "검색 결과가 없습니다.",
35
36
  "temp": "임시",
36
37
  "title": "주제"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "Onderwerpenlijst"
33
33
  },
34
34
  "searchPlaceholder": "Zoek onderwerpen...",
35
+ "searchResultEmpty": "Geen zoekresultaten gevonden",
35
36
  "temp": "Tijdelijk",
36
37
  "title": "Onderwerp"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "Lista tematów"
33
33
  },
34
34
  "searchPlaceholder": "Szukaj tematów...",
35
+ "searchResultEmpty": "Brak wyników wyszukiwania",
35
36
  "temp": "Tymczasowy",
36
37
  "title": "Temat"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "Lista de tópicos"
33
33
  },
34
34
  "searchPlaceholder": "Pesquisar tópicos...",
35
+ "searchResultEmpty": "Nenhum resultado encontrado",
35
36
  "temp": "Temporário",
36
37
  "title": "Tópico"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "Список тем"
33
33
  },
34
34
  "searchPlaceholder": "Поиск тем...",
35
+ "searchResultEmpty": "Нет результатов поиска",
35
36
  "temp": "Временный",
36
37
  "title": "Тема"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "Konu Listesi"
33
33
  },
34
34
  "searchPlaceholder": "Konuları Ara...",
35
+ "searchResultEmpty": "Hiçbir arama sonucu bulunamadı",
35
36
  "temp": "Geçici",
36
37
  "title": "Konu"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "Danh sách chủ đề"
33
33
  },
34
34
  "searchPlaceholder": "Tìm kiếm chủ đề...",
35
+ "searchResultEmpty": "Không có kết quả tìm kiếm nào",
35
36
  "temp": "Tạm thời",
36
37
  "title": "Chủ đề"
37
38
  }
@@ -32,6 +32,7 @@
32
32
  "title": "话题列表"
33
33
  },
34
34
  "searchPlaceholder": "搜索话题...",
35
+ "searchResultEmpty": "暂无搜索结果",
35
36
  "temp": "临时",
36
37
  "title": "话题"
37
- }
38
+ }
@@ -32,6 +32,7 @@
32
32
  "title": "話題列表"
33
33
  },
34
34
  "searchPlaceholder": "搜尋話題...",
35
+ "searchResultEmpty": "目前沒有搜尋結果",
35
36
  "temp": "臨時",
36
37
  "title": "話題"
37
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.70.7",
3
+ "version": "1.70.9",
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
- clerkAuth = getAuth(req as NextRequest);
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: 24px !important;
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: 6 }).map((_, i) => (
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;
@@ -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 activeTopicList = useChatStore(topicSelectors.displayTopics, isEqual);
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 || !activeTopicList) return <SkeletonList />;
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 [keywords, setKeywords] = useState('');
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(keywords, activeSessionId);
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 (keywords === '') onClear?.();
36
+ if (tempValue === '') {
37
+ onClear?.();
38
+
39
+ return;
40
+ }
41
+
42
+ startSearchTopic();
27
43
  }}
28
44
  onChange={(e) => {
29
- const value = e.target.value;
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={keywords}
51
+ value={tempValue}
38
52
  />
39
53
  );
40
54
  });
@@ -39,9 +39,6 @@ export const userSettings = pgTable('user_settings', {
39
39
  .primaryKey(),
40
40
 
41
41
  tts: jsonb('tts'),
42
- /**
43
- * @deprecated
44
- */
45
42
  keyVaults: text('key_vaults'),
46
43
  general: jsonb('general'),
47
44
  languageModel: jsonb('language_model'),
@@ -1,5 +1,5 @@
1
- import { Column, count, sql } from 'drizzle-orm';
2
- import { and, desc, eq, exists, gt, inArray, isNull, like, or } from 'drizzle-orm/expressions';
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
- const matchKeyword = (field: any) =>
83
- like(sql`lower(${field})` as unknown as Column, `%${keywordLowerCase}%`);
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
- or(
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) => {
@@ -14,7 +14,11 @@ import Link from 'next/link';
14
14
  import { ReactNode, memo, useEffect } from 'react';
15
15
 
16
16
  import AntdStaticMethods from '@/components/AntdStaticMethods';
17
- import { LOBE_THEME_NEUTRAL_COLOR, LOBE_THEME_PRIMARY_COLOR } from '@/const/theme';
17
+ import {
18
+ LOBE_THEME_APPEARANCE,
19
+ LOBE_THEME_NEUTRAL_COLOR,
20
+ LOBE_THEME_PRIMARY_COLOR,
21
+ } from '@/const/theme';
18
22
  import { useGlobalStore } from '@/store/global';
19
23
  import { systemStatusSelectors } from '@/store/global/selectors';
20
24
  import { useUserStore } from '@/store/user';
@@ -125,6 +129,11 @@ const AppTheme = memo<AppThemeProps>(
125
129
  primaryColor: primaryColor ?? defaultPrimaryColor,
126
130
  }}
127
131
  defaultAppearance={defaultAppearance}
132
+ onAppearanceChange={(appearance) => {
133
+ if (themeMode !== 'auto') return;
134
+
135
+ setCookie(LOBE_THEME_APPEARANCE, appearance);
136
+ }}
128
137
  theme={{
129
138
  cssVar: true,
130
139
  token: {
@@ -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();
@@ -32,6 +32,7 @@ export default {
32
32
  title: '话题列表',
33
33
  },
34
34
  searchPlaceholder: '搜索话题...',
35
+ searchResultEmpty: '暂无搜索结果',
35
36
  temp: '临时',
36
37
  title: '话题',
37
38
  };
@@ -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?: 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?: 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
- auth = getAuth(request);
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 currentUser();
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({ searchTopics: data }, false, n('useSearchTopics(success)', { keywords }));
221
+ set(
222
+ { searchTopics: data, isSearchingTopic: false },
223
+ false,
224
+ n('useSearchTopics(success)', { keywords }),
225
+ );
222
226
  },
223
227
  },
224
228
  ),
@@ -4,6 +4,7 @@ export interface ChatTopicState {
4
4
  // TODO: need to add the null to the type
5
5
  activeTopicId?: string;
6
6
  creatingTopic: boolean;
7
+ inSearchingMode?: boolean;
7
8
  isSearchingTopic: boolean;
8
9
  searchTopics: ChatTopic[];
9
10
  topicLoadingIds: string[];
@@ -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, { isSearchingTopic: true, searchTopics });
83
- const topics = topicSelectors.displayTopics(state);
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
  };
@@ -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 = await auth();
7
+ const clerkAuth = new ClerkAuth();
9
8
 
10
- const userId = clerkAuth.userId;
11
- return { clerkAuth: auth, userId };
9
+ return await clerkAuth.getAuth();
12
10
  }
13
11
 
14
12
  if (enableNextAuth) {