@lobehub/lobehub 2.0.0-next.224 → 2.0.0-next.225

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.
@@ -12,7 +12,8 @@ concurrency:
12
12
 
13
13
  jobs:
14
14
  # Check for duplicate runs
15
- pre_job:
15
+ check-duplicate-run:
16
+ name: Check Duplicate Run
16
17
  runs-on: ubuntu-latest
17
18
  outputs:
18
19
  should_skip: ${{ steps.skip_check.outputs.should_skip }}
@@ -26,8 +27,8 @@ jobs:
26
27
 
27
28
  # Package tests - all packages in single job to save runner resources
28
29
  test-packages:
29
- needs: pre_job
30
- if: needs.pre_job.outputs.should_skip != 'true'
30
+ needs: check-duplicate-run
31
+ if: needs.check-duplicate-run.outputs.should_skip != 'true'
31
32
  runs-on: ubuntu-latest
32
33
  name: Test Packages
33
34
  env:
@@ -61,21 +62,24 @@ jobs:
61
62
  - name: Upload coverage to Codecov
62
63
  if: always()
63
64
  run: |
65
+ curl -Os https://cli.codecov.io/latest/linux/codecov
66
+ chmod +x codecov
64
67
  for package in $PACKAGES; do
65
- # Extract directory name: @lobechat/file-loaders -> file-loaders, model-bank -> model-bank
66
68
  dir="${package#@lobechat/}"
67
69
  if [ -f "./packages/$dir/coverage/lcov.info" ]; then
68
- echo "Uploading coverage for $package..."
69
- npx codecov --token=${{ secrets.CODECOV_TOKEN }} \
70
- --file=./packages/$dir/coverage/lcov.info \
71
- --flags=packages/$dir
70
+ echo "Uploading coverage for $dir..."
71
+ ./codecov upload-process \
72
+ -t ${{ secrets.CODECOV_TOKEN }} \
73
+ -f ./packages/$dir/coverage/lcov.info \
74
+ -F packages/$dir \
75
+ --disable-search
72
76
  fi
73
77
  done
74
78
 
75
79
  # App tests
76
80
  test-website:
77
- needs: pre_job
78
- if: needs.pre_job.outputs.should_skip != 'true'
81
+ needs: check-duplicate-run
82
+ if: needs.check-duplicate-run.outputs.should_skip != 'true'
79
83
  name: Test Website
80
84
 
81
85
  runs-on: ubuntu-latest
@@ -108,8 +112,8 @@ jobs:
108
112
  flags: app
109
113
 
110
114
  test-desktop:
111
- needs: pre_job
112
- if: needs.pre_job.outputs.should_skip != 'true'
115
+ needs: check-duplicate-run
116
+ if: needs.check-duplicate-run.outputs.should_skip != 'true'
113
117
  name: Test Desktop App
114
118
 
115
119
  runs-on: ubuntu-latest
@@ -150,8 +154,8 @@ jobs:
150
154
  flags: desktop
151
155
 
152
156
  test-databsae:
153
- needs: pre_job
154
- if: needs.pre_job.outputs.should_skip != 'true'
157
+ needs: check-duplicate-run
158
+ if: needs.check-duplicate-run.outputs.should_skip != 'true'
155
159
  name: Test Database
156
160
 
157
161
  runs-on: ubuntu-latest
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 2.0.0-next.225](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.224...v2.0.0-next.225)
6
+
7
+ <sup>Released on **2026-01-06**</sup>
8
+
9
+ #### ✨ Features
10
+
11
+ - **ModelSwitchPanel**: Add provider preference storage in By Model view.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's improved
19
+
20
+ - **ModelSwitchPanel**: Add provider preference storage in By Model view, closes [#11246](https://github.com/lobehub/lobe-chat/issues/11246) ([d778093](https://github.com/lobehub/lobe-chat/commit/d778093))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
5
30
  ## [Version 2.0.0-next.224](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.223...v2.0.0-next.224)
6
31
 
7
32
  <sup>Released on **2026-01-06**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "children": {},
4
+ "date": "2026-01-06",
5
+ "version": "2.0.0-next.225"
6
+ },
2
7
  {
3
8
  "children": {},
4
9
  "date": "2026-01-06",
@@ -119,8 +119,8 @@
119
119
  "cmdk.navigate": "Navigate",
120
120
  "cmdk.newAgent": "Create New Agent",
121
121
  "cmdk.newAgentTeam": "Create New Group",
122
- "cmdk.newLibrary": "New Library",
123
- "cmdk.newPage": "New Page",
122
+ "cmdk.newLibrary": "Create New Library",
123
+ "cmdk.newPage": "Create New Page",
124
124
  "cmdk.newTopic": "New topic in current Agent",
125
125
  "cmdk.noResults": "No results found",
126
126
  "cmdk.openSettings": "Open Settings",
@@ -158,6 +158,7 @@
158
158
  "cmdk.themeLight": "Light",
159
159
  "cmdk.toOpen": "Open",
160
160
  "cmdk.toSelect": "Select",
161
+ "cmdk.upgradePlan": "Upgrade Plan",
161
162
  "confirm": "Confirm",
162
163
  "contact": "Contact Us",
163
164
  "copy": "Copy",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/lobehub",
3
- "version": "2.0.0-next.224",
3
+ "version": "2.0.0-next.225",
4
4
  "description": "LobeHub - an open-source,comprehensive AI Agent 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,5 +1,6 @@
1
1
  import {
2
2
  SSOProvider,
3
+ UserGeneralConfig,
3
4
  UserGuide,
4
5
  UserKeyVaults,
5
6
  UserPreference,
@@ -45,6 +46,11 @@ export type ListUsersForMemoryExtractorOptions = {
45
46
  whitelist?: string[];
46
47
  };
47
48
 
49
+ export interface UserInfoForAIGeneration {
50
+ responseLanguage: string;
51
+ userName: string;
52
+ }
53
+
48
54
  export class UserModel {
49
55
  private userId: string;
50
56
  private db: LobeChatDatabase;
@@ -339,4 +345,31 @@ export class UserModel {
339
345
  where,
340
346
  });
341
347
  };
348
+
349
+ /**
350
+ * Get user info for AI generation (name and language preference)
351
+ */
352
+ static getInfoForAIGeneration = async (
353
+ db: LobeChatDatabase,
354
+ userId: string,
355
+ ): Promise<UserInfoForAIGeneration> => {
356
+ const result = await db
357
+ .select({
358
+ firstName: users.firstName,
359
+ fullName: users.fullName,
360
+ general: userSettings.general,
361
+ })
362
+ .from(users)
363
+ .leftJoin(userSettings, eq(users.id, userSettings.id))
364
+ .where(eq(users.id, userId))
365
+ .limit(1);
366
+
367
+ const user = result[0];
368
+ const general = user?.general as UserGeneralConfig | undefined;
369
+
370
+ return {
371
+ responseLanguage: general?.responseLanguage || 'en-US',
372
+ userName: user?.fullName || user?.firstName || 'User',
373
+ };
374
+ };
342
375
  }
@@ -29,7 +29,7 @@ export interface KnowledgeItem {
29
29
  }
30
30
 
31
31
  /**
32
- * Knowledge Repository - combines files and documents into a unified interface
32
+ * Resources Repository - combines files and documents into a unified interface
33
33
  */
34
34
  export class KnowledgeRepo {
35
35
  private userId: string;
@@ -0,0 +1,10 @@
1
+ import { type ChatMessageError } from '@lobechat/types';
2
+
3
+ export default function useRenderBusinessChatErrorMessageExtra(
4
+ // eslint-disable-next-line unused-imports/no-unused-vars, @typescript-eslint/no-unused-vars
5
+ error: ChatMessageError | null | undefined,
6
+ // eslint-disable-next-line unused-imports/no-unused-vars, @typescript-eslint/no-unused-vars
7
+ messageId: string,
8
+ ) {
9
+ return null;
10
+ }
@@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next';
6
6
  import { useCommandMenuContext } from './CommandMenuContext';
7
7
  import { CommandItem } from './components';
8
8
  import { useCommandMenu } from './useCommandMenu';
9
- import { getContextCommands } from './utils/contextCommands';
9
+ import { CONTEXT_COMMANDS, getContextCommands } from './utils/contextCommands';
10
10
 
11
11
  const ContextCommands = memo(() => {
12
12
  const { t } = useTranslation('setting');
13
13
  const { t: tAuth } = useTranslation('auth');
14
+ const { t: tSubscription } = useTranslation('subscription');
14
15
  const { t: tCommon } = useTranslation('common');
15
16
  const { handleNavigate } = useCommandMenu();
16
17
  const { menuContext, pathname } = useCommandMenuContext();
@@ -23,48 +24,107 @@ const ContextCommands = memo(() => {
23
24
 
24
25
  const commands = getContextCommands(menuContext, subPath);
25
26
 
26
- if (commands.length === 0) return null;
27
+ // Get settings commands to show globally (when not in settings context)
28
+ const globalSettingsCommands = useMemo(() => {
29
+ if (menuContext === 'settings') return [];
30
+ return CONTEXT_COMMANDS.settings;
31
+ }, [menuContext]);
32
+
33
+ const hasCommands = commands.length > 0 || globalSettingsCommands.length > 0;
34
+
35
+ if (!hasCommands) return null;
27
36
 
28
37
  // Get localized context name
29
38
  const contextName = tCommon(`cmdk.context.${menuContext}`, { defaultValue: menuContext });
39
+ const settingsContextName = tCommon('cmdk.context.settings', { defaultValue: 'settings' });
30
40
 
31
41
  return (
32
- <Command.Group>
33
- {commands.map((cmd) => {
34
- const Icon = cmd.icon;
35
- // Get localized label using the correct namespace
36
- let label = cmd.label;
37
- if (cmd.labelKey) {
38
- if (cmd.labelNamespace === 'auth') {
39
- label = tAuth(cmd.labelKey, { defaultValue: cmd.label });
40
- } else {
41
- label = t(cmd.labelKey, { defaultValue: cmd.label });
42
- }
43
- }
44
- const searchValue = `${contextName} ${label} ${cmd.keywords.join(' ')}`;
42
+ <>
43
+ {/* Current context commands */}
44
+ {commands.length > 0 && (
45
+ <Command.Group>
46
+ {commands.map((cmd) => {
47
+ const Icon = cmd.icon;
48
+ // Get localized label using the correct namespace
49
+ let label = cmd.label;
50
+ if (cmd.labelKey) {
51
+ if (cmd.labelNamespace === 'auth') {
52
+ label = tAuth(cmd.labelKey, { defaultValue: cmd.label });
53
+ } else if (cmd.labelNamespace === 'subscription') {
54
+ label = tSubscription(cmd.labelKey, { defaultValue: cmd.label });
55
+ } else {
56
+ label = t(cmd.labelKey, { defaultValue: cmd.label });
57
+ }
58
+ }
59
+ const searchValue = `${contextName} ${label} ${cmd.keywords.join(' ')}`;
60
+
61
+ return (
62
+ <CommandItem
63
+ icon={<Icon />}
64
+ key={cmd.path}
65
+ onSelect={() => handleNavigate(cmd.path)}
66
+ value={searchValue}
67
+ >
68
+ <span style={{ opacity: 0.5 }}>{contextName}</span>
69
+ <ChevronRight
70
+ size={14}
71
+ style={{
72
+ display: 'inline',
73
+ marginInline: '6px',
74
+ opacity: 0.5,
75
+ verticalAlign: 'middle',
76
+ }}
77
+ />
78
+ {label}
79
+ </CommandItem>
80
+ );
81
+ })}
82
+ </Command.Group>
83
+ )}
84
+
85
+ {/* Global settings commands (searchable from any page) */}
86
+ {globalSettingsCommands.length > 0 && (
87
+ <Command.Group>
88
+ {globalSettingsCommands.map((cmd) => {
89
+ const Icon = cmd.icon;
90
+ // Get localized label using the correct namespace
91
+ let label = cmd.label;
92
+ if (cmd.labelKey) {
93
+ if (cmd.labelNamespace === 'auth') {
94
+ label = tAuth(cmd.labelKey, { defaultValue: cmd.label });
95
+ } else if (cmd.labelNamespace === 'subscription') {
96
+ label = tSubscription(cmd.labelKey, { defaultValue: cmd.label });
97
+ } else {
98
+ label = t(cmd.labelKey, { defaultValue: cmd.label });
99
+ }
100
+ }
101
+ const searchValue = `${settingsContextName} ${label} ${cmd.keywords.join(' ')}`;
45
102
 
46
- return (
47
- <CommandItem
48
- icon={<Icon />}
49
- key={cmd.path}
50
- onSelect={() => handleNavigate(cmd.path)}
51
- value={searchValue}
52
- >
53
- <span style={{ opacity: 0.5 }}>{contextName}</span>
54
- <ChevronRight
55
- size={14}
56
- style={{
57
- display: 'inline',
58
- marginInline: '6px',
59
- opacity: 0.5,
60
- verticalAlign: 'middle',
61
- }}
62
- />
63
- {label}
64
- </CommandItem>
65
- );
66
- })}
67
- </Command.Group>
103
+ return (
104
+ <CommandItem
105
+ icon={<Icon />}
106
+ key={cmd.path}
107
+ onSelect={() => handleNavigate(cmd.path)}
108
+ unpinned={true}
109
+ value={searchValue}
110
+ >
111
+ <span style={{ opacity: 0.5 }}>{settingsContextName}</span>
112
+ <ChevronRight
113
+ size={14}
114
+ style={{
115
+ display: 'inline',
116
+ marginInline: '6px',
117
+ opacity: 0.5,
118
+ verticalAlign: 'middle',
119
+ }}
120
+ />
121
+ {label}
122
+ </CommandItem>
123
+ );
124
+ })}
125
+ </Command.Group>
126
+ )}
127
+ </>
68
128
  );
69
129
  });
70
130
 
@@ -1,6 +1,15 @@
1
1
  import { Command } from 'cmdk';
2
2
  import dayjs from 'dayjs';
3
- import { Bot, FileText, MessageCircle, MessageSquare, Plug, Puzzle, Sparkles } from 'lucide-react';
3
+ import {
4
+ Bot,
5
+ ChevronRight,
6
+ FileText,
7
+ MessageCircle,
8
+ MessageSquare,
9
+ Plug,
10
+ Puzzle,
11
+ Sparkles,
12
+ } from 'lucide-react';
4
13
  import { markdownToTxt } from 'markdown-to-txt';
5
14
  import { memo } from 'react';
6
15
  import { useTranslation } from 'react-i18next';
@@ -8,7 +17,6 @@ import { useNavigate } from 'react-router-dom';
8
17
 
9
18
  import type { SearchResult } from '@/database/repositories/search';
10
19
 
11
- import { useCommandMenuContext } from './CommandMenuContext';
12
20
  import { CommandItem } from './components';
13
21
  import { styles } from './styles';
14
22
  import type { ValidSearchType } from './utils/queryParser';
@@ -29,7 +37,6 @@ const SearchResults = memo<SearchResultsProps>(
29
37
  ({ isLoading, onClose, onSetTypeFilter, results, searchQuery, typeFilter }) => {
30
38
  const { t } = useTranslation('common');
31
39
  const navigate = useNavigate();
32
- const { menuContext } = useCommandMenuContext();
33
40
 
34
41
  const handleNavigate = (result: SearchResult) => {
35
42
  switch (result.type) {
@@ -146,15 +153,6 @@ const SearchResults = memo<SearchResultsProps>(
146
153
  }
147
154
  };
148
155
 
149
- // Get trailing label for search results (shows "Market" for marketplace items)
150
- const getTrailingLabel = (type: SearchResult['type']) => {
151
- // Marketplace items: MCP, plugins, assistants
152
- if (type === 'mcp' || type === 'plugin' || type === 'communityAgent') {
153
- return t('cmdk.search.market');
154
- }
155
- return getTypeLabel(type);
156
- };
157
-
158
156
  // eslint-disable-next-line unicorn/consistent-function-scoping
159
157
  const getItemValue = (result: SearchResult) => {
160
158
  const meta = [result.title, result.description].filter(Boolean).join(' ');
@@ -193,26 +191,6 @@ const SearchResults = memo<SearchResultsProps>(
193
191
  onSetTypeFilter(type);
194
192
  };
195
193
 
196
- // Helper to render "Search More" button
197
- const renderSearchMore = (type: ValidSearchType, count: number) => {
198
- // Don't show if already filtering by this type
199
- if (typeFilter) return null;
200
-
201
- // Show if there are results (might have more)
202
- if (count === 0) return null;
203
-
204
- return (
205
- <CommandItem
206
- forceMount
207
- icon={getIcon(type)}
208
- onSelect={() => handleSearchMore(type)}
209
- title={t('cmdk.search.searchMore', { type: getTypeLabel(type) })}
210
- value={`action-show-more-results-for-type-${type}`}
211
- variant="detailed"
212
- />
213
- );
214
- };
215
-
216
194
  const hasResults = results.length > 0;
217
195
 
218
196
  // Group results by type
@@ -225,289 +203,135 @@ const SearchResults = memo<SearchResultsProps>(
225
203
  const pluginResults = results.filter((r) => r.type === 'plugin');
226
204
  const assistantResults = results.filter((r) => r.type === 'communityAgent');
227
205
 
228
- // Detect context types
229
- const isResourceContext = menuContext === 'resource';
230
- const isPageContext = menuContext === 'page';
231
-
232
206
  // Don't render anything if no results and not loading
233
207
  if (!hasResults && !isLoading) {
234
208
  return null;
235
209
  }
236
210
 
237
- return (
238
- <>
239
- {/* Show pages first in page context */}
240
- {hasResults && isPageContext && pageResults.length > 0 && (
241
- <Command.Group heading={t('cmdk.search.pages')} key="pages-page-context">
242
- {pageResults.map((result) => (
243
- <CommandItem
244
- description={result.description}
245
- icon={getIcon(result.type)}
246
- key={`page-page-context-${result.id}`}
247
- onSelect={() => handleNavigate(result)}
248
- title={result.title}
249
- trailingLabel={getTrailingLabel(result.type)}
250
- value={getItemValue(result)}
251
- variant="detailed"
252
- />
253
- ))}
254
- {renderSearchMore('page', pageResults.length)}
255
- </Command.Group>
256
- )}
257
-
258
- {/* Show other results in page context */}
259
- {hasResults && isPageContext && fileResults.length > 0 && (
260
- <Command.Group heading={t('cmdk.search.files')}>
261
- {fileResults.map((result) => (
262
- <CommandItem
263
- description={result.type === 'file' ? result.fileType : undefined}
264
- icon={getIcon(result.type)}
265
- key={`file-page-context-${result.id}`}
266
- onSelect={() => handleNavigate(result)}
267
- title={result.title}
268
- trailingLabel={getTrailingLabel(result.type)}
269
- value={getItemValue(result)}
270
- variant="detailed"
271
- />
272
- ))}
273
- {renderSearchMore('file', fileResults.length)}
274
- </Command.Group>
275
- )}
211
+ // Render a single result item with type prefix (like "Message > content")
212
+ const renderResultItem = (result: SearchResult) => {
213
+ const typeLabel = getTypeLabel(result.type);
214
+ const subtitle = getSubtitle(result);
215
+
216
+ // Hide type prefix when filtering by specific type
217
+ const showTypePrefix = !typeFilter;
218
+
219
+ // Create title with or without type prefix
220
+ const titleWithPrefix = showTypePrefix ? (
221
+ <>
222
+ <span style={{ opacity: 0.5 }}>{typeLabel}</span>
223
+ <ChevronRight
224
+ size={14}
225
+ style={{
226
+ display: 'inline',
227
+ marginInline: '6px',
228
+ opacity: 0.5,
229
+ verticalAlign: 'middle',
230
+ }}
231
+ />
232
+ {result.title}
233
+ </>
234
+ ) : (
235
+ result.title
236
+ );
276
237
 
277
- {hasResults && isPageContext && agentResults.length > 0 && (
278
- <Command.Group heading={t('cmdk.search.agents')}>
279
- {agentResults.map((result) => (
280
- <CommandItem
281
- description={getDescription(result)}
282
- icon={getIcon(result.type)}
283
- key={`agent-page-context-${result.id}`}
284
- onSelect={() => handleNavigate(result)}
285
- title={result.title}
286
- trailingLabel={getTrailingLabel(result.type)}
287
- value={getItemValue(result)}
288
- variant="detailed"
289
- />
290
- ))}
291
- {renderSearchMore('agent', agentResults.length)}
292
- </Command.Group>
293
- )}
238
+ return (
239
+ <CommandItem
240
+ description={subtitle}
241
+ icon={getIcon(result.type)}
242
+ key={result.id}
243
+ onSelect={() => handleNavigate(result)}
244
+ title={titleWithPrefix}
245
+ value={getItemValue(result)}
246
+ variant="detailed"
247
+ />
248
+ );
249
+ };
294
250
 
295
- {hasResults && isPageContext && topicResults.length > 0 && (
296
- <Command.Group heading={t('cmdk.search.topics')}>
297
- {topicResults.map((result) => (
298
- <CommandItem
299
- description={getSubtitle(result)}
300
- icon={getIcon(result.type)}
301
- key={`topic-page-context-${result.id}`}
302
- onSelect={() => handleNavigate(result)}
303
- title={result.title}
304
- trailingLabel={getTrailingLabel(result.type)}
305
- value={getItemValue(result)}
306
- variant="detailed"
307
- />
308
- ))}
309
- {renderSearchMore('topic', topicResults.length)}
310
- </Command.Group>
311
- )}
251
+ // Helper to render "Search More" button
252
+ const renderSearchMore = (type: ValidSearchType, count: number) => {
253
+ // Don't show if already filtering by this type
254
+ if (typeFilter) return null;
312
255
 
313
- {hasResults && isPageContext && messageResults.length > 0 && (
314
- <Command.Group heading={t('cmdk.search.messages')}>
315
- {messageResults.map((result) => (
316
- <CommandItem
317
- description={getSubtitle(result)}
318
- icon={getIcon(result.type)}
319
- key={`message-page-context-${result.id}`}
320
- onSelect={() => handleNavigate(result)}
321
- title={result.title}
322
- trailingLabel={getTrailingLabel(result.type)}
323
- value={getItemValue(result)}
324
- variant="detailed"
325
- />
326
- ))}
327
- {renderSearchMore('message', messageResults.length)}
328
- </Command.Group>
329
- )}
256
+ // Show if there are results (might have more)
257
+ if (count === 0) return null;
330
258
 
331
- {/* Show pages first in resource context */}
332
- {hasResults && isResourceContext && pageResults.length > 0 && (
333
- <Command.Group heading={t('cmdk.search.pages')} key="pages-resource">
334
- {pageResults.map((result) => (
335
- <CommandItem
336
- description={result.description}
337
- icon={getIcon(result.type)}
338
- key={`page-resource-${result.id}`}
339
- onSelect={() => handleNavigate(result)}
340
- title={result.title}
341
- trailingLabel={getTrailingLabel(result.type)}
342
- value={getItemValue(result)}
343
- variant="detailed"
344
- />
345
- ))}
346
- {renderSearchMore('page', pageResults.length)}
347
- </Command.Group>
348
- )}
259
+ const typeLabel = getTypeLabel(type);
260
+ const titleText = `${t('cmdk.search.searchMore', { type: typeLabel })} with "${searchQuery}"`;
349
261
 
350
- {/* Show files in resource context */}
351
- {hasResults && isResourceContext && fileResults.length > 0 && (
352
- <Command.Group heading={t('cmdk.search.files')}>
353
- {fileResults.map((result) => (
354
- <CommandItem
355
- description={result.type === 'file' ? result.fileType : undefined}
356
- icon={getIcon(result.type)}
357
- key={`file-${result.id}`}
358
- onSelect={() => handleNavigate(result)}
359
- title={result.title}
360
- trailingLabel={getTrailingLabel(result.type)}
361
- value={getItemValue(result)}
362
- variant="detailed"
363
- />
364
- ))}
365
- {renderSearchMore('file', fileResults.length)}
366
- </Command.Group>
367
- )}
262
+ return (
263
+ <Command.Item
264
+ forceMount
265
+ key={`search-more-${type}`}
266
+ keywords={[`zzz-action-${type}`]}
267
+ onSelect={() => handleSearchMore(type)}
268
+ value={`zzz-action-${type}-search-more`}
269
+ >
270
+ <div className={styles.itemContent}>
271
+ <div className={styles.itemIcon}>{getIcon(type)}</div>
272
+ <div className={styles.itemDetails}>
273
+ <div className={styles.itemTitle}>{titleText}</div>
274
+ </div>
275
+ </div>
276
+ </Command.Item>
277
+ );
278
+ };
368
279
 
369
- {hasResults && !isPageContext && !isResourceContext && messageResults.length > 0 && (
370
- <Command.Group heading={t('cmdk.search.messages')}>
371
- {messageResults.map((result) => (
372
- <CommandItem
373
- description={getSubtitle(result)}
374
- icon={getIcon(result.type)}
375
- key={`message-${result.id}`}
376
- onSelect={() => handleNavigate(result)}
377
- title={result.title}
378
- trailingLabel={getTrailingLabel(result.type)}
379
- value={getItemValue(result)}
380
- variant="detailed"
381
- />
382
- ))}
280
+ return (
281
+ <>
282
+ {/* Render search results grouped by type without headers */}
283
+ {messageResults.length > 0 && (
284
+ <Command.Group>
285
+ {messageResults.map((result) => renderResultItem(result))}
383
286
  {renderSearchMore('message', messageResults.length)}
384
287
  </Command.Group>
385
288
  )}
386
289
 
387
- {hasResults && !isPageContext && agentResults.length > 0 && (
388
- <Command.Group heading={t('cmdk.search.agents')}>
389
- {agentResults.map((result) => (
390
- <CommandItem
391
- description={getDescription(result)}
392
- icon={getIcon(result.type)}
393
- key={`agent-${result.id}`}
394
- onSelect={() => handleNavigate(result)}
395
- title={result.title}
396
- trailingLabel={getTrailingLabel(result.type)}
397
- value={getItemValue(result)}
398
- variant="detailed"
399
- />
400
- ))}
290
+ {agentResults.length > 0 && (
291
+ <Command.Group>
292
+ {agentResults.map((result) => renderResultItem(result))}
401
293
  {renderSearchMore('agent', agentResults.length)}
402
294
  </Command.Group>
403
295
  )}
404
296
 
405
- {hasResults && !isPageContext && topicResults.length > 0 && (
406
- <Command.Group heading={t('cmdk.search.topics')}>
407
- {topicResults.map((result) => (
408
- <CommandItem
409
- description={getSubtitle(result)}
410
- icon={getIcon(result.type)}
411
- key={`topic-${result.id}`}
412
- onSelect={() => handleNavigate(result)}
413
- title={result.title}
414
- trailingLabel={getTrailingLabel(result.type)}
415
- value={getItemValue(result)}
416
- variant="detailed"
417
- />
418
- ))}
297
+ {topicResults.length > 0 && (
298
+ <Command.Group>
299
+ {topicResults.map((result) => renderResultItem(result))}
419
300
  {renderSearchMore('topic', topicResults.length)}
420
301
  </Command.Group>
421
302
  )}
422
303
 
423
- {/* Show document pages in normal context (not in resource or page context) */}
424
- {hasResults && !isResourceContext && !isPageContext && pageResults.length > 0 && (
425
- <Command.Group heading={t('cmdk.search.pages')} key="pages-normal">
426
- {pageResults.map((result) => (
427
- <CommandItem
428
- description={result.description}
429
- icon={getIcon(result.type)}
430
- key={`page-normal-${result.id}`}
431
- onSelect={() => handleNavigate(result)}
432
- title={result.title}
433
- trailingLabel={getTrailingLabel(result.type)}
434
- value={getItemValue(result)}
435
- variant="detailed"
436
- />
437
- ))}
304
+ {pageResults.length > 0 && (
305
+ <Command.Group>
306
+ {pageResults.map((result) => renderResultItem(result))}
438
307
  {renderSearchMore('page', pageResults.length)}
439
308
  </Command.Group>
440
309
  )}
441
310
 
442
- {/* Show files in original position when NOT in resource or page context */}
443
- {hasResults && !isResourceContext && !isPageContext && fileResults.length > 0 && (
444
- <Command.Group heading={t('cmdk.search.files')}>
445
- {fileResults.map((result) => (
446
- <CommandItem
447
- description={result.type === 'file' ? result.fileType : undefined}
448
- icon={getIcon(result.type)}
449
- key={`file-${result.id}`}
450
- onSelect={() => handleNavigate(result)}
451
- title={result.title}
452
- trailingLabel={getTrailingLabel(result.type)}
453
- value={getItemValue(result)}
454
- variant="detailed"
455
- />
456
- ))}
311
+ {fileResults.length > 0 && (
312
+ <Command.Group>
313
+ {fileResults.map((result) => renderResultItem(result))}
457
314
  {renderSearchMore('file', fileResults.length)}
458
315
  </Command.Group>
459
316
  )}
460
317
 
461
- {hasResults && mcpResults.length > 0 && (
462
- <Command.Group heading={t('cmdk.search.mcps')}>
463
- {mcpResults.map((result) => (
464
- <CommandItem
465
- description={getDescription(result)}
466
- icon={getIcon(result.type)}
467
- key={`mcp-${result.id}`}
468
- onSelect={() => handleNavigate(result)}
469
- title={result.title}
470
- trailingLabel={getTrailingLabel(result.type)}
471
- value={getItemValue(result)}
472
- variant="detailed"
473
- />
474
- ))}
318
+ {mcpResults.length > 0 && (
319
+ <Command.Group>
320
+ {mcpResults.map((result) => renderResultItem(result))}
475
321
  {renderSearchMore('mcp', mcpResults.length)}
476
322
  </Command.Group>
477
323
  )}
478
324
 
479
- {hasResults && pluginResults.length > 0 && (
480
- <Command.Group heading={t('cmdk.search.plugins')}>
481
- {pluginResults.map((result) => (
482
- <CommandItem
483
- description={getDescription(result)}
484
- icon={getIcon(result.type)}
485
- key={`plugin-${result.id}`}
486
- onSelect={() => handleNavigate(result)}
487
- title={result.title}
488
- trailingLabel={getTrailingLabel(result.type)}
489
- value={getItemValue(result)}
490
- variant="detailed"
491
- />
492
- ))}
325
+ {pluginResults.length > 0 && (
326
+ <Command.Group>
327
+ {pluginResults.map((result) => renderResultItem(result))}
493
328
  {renderSearchMore('plugin', pluginResults.length)}
494
329
  </Command.Group>
495
330
  )}
496
331
 
497
- {hasResults && assistantResults.length > 0 && (
498
- <Command.Group heading={t('cmdk.search.assistants')}>
499
- {assistantResults.map((result) => (
500
- <CommandItem
501
- description={getDescription(result)}
502
- icon={getIcon(result.type)}
503
- key={`assistant-${result.id}`}
504
- onSelect={() => handleNavigate(result)}
505
- title={result.title}
506
- trailingLabel={getTrailingLabel(result.type)}
507
- value={getItemValue(result)}
508
- variant="detailed"
509
- />
510
- ))}
332
+ {assistantResults.length > 0 && (
333
+ <Command.Group>
334
+ {assistantResults.map((result) => renderResultItem(result))}
511
335
  {renderSearchMore('communityAgent', assistantResults.length)}
512
336
  </Command.Group>
513
337
  )}
@@ -5,7 +5,7 @@ import { cloneElement, isValidElement, memo } from 'react';
5
5
  import { useCommandMenuContext } from '../CommandMenuContext';
6
6
  import { styles } from '../styles';
7
7
 
8
- type BaseCommandItemProps = Omit<ComponentProps<typeof Command.Item>, 'children'> & {
8
+ type BaseCommandItemProps = Omit<ComponentProps<typeof Command.Item>, 'children' | 'title'> & {
9
9
  /**
10
10
  * Hide the item from default view but keep it searchable
11
11
  * When true, the item won't show in the default list but will appear in search results
@@ -1,13 +1,19 @@
1
+ import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
1
2
  import type { LucideIcon } from 'lucide-react';
2
3
  import {
3
4
  Brain,
4
5
  ChartColumnBigIcon,
6
+ Coins,
7
+ CreditCard,
5
8
  EthernetPort,
9
+ Gift,
6
10
  Image as ImageIcon,
7
11
  Info,
8
12
  KeyIcon,
9
13
  KeyboardIcon,
14
+ Map,
10
15
  Palette as PaletteIcon,
16
+ PieChart,
11
17
  UserCircle,
12
18
  } from 'lucide-react';
13
19
 
@@ -18,7 +24,7 @@ export interface ContextCommand {
18
24
  keywords: string[];
19
25
  label: string;
20
26
  labelKey?: string; // i18n key for the label
21
- labelNamespace?: 'setting' | 'auth'; // i18n namespace for the label
27
+ labelNamespace?: 'setting' | 'auth' | 'subscription'; // i18n namespace for the label
22
28
  path: string;
23
29
  subPath: string;
24
30
  }
@@ -114,6 +120,55 @@ export const CONTEXT_COMMANDS: Record<ContextType, ContextCommand[]> = {
114
120
  path: '/settings/about',
115
121
  subPath: 'about',
116
122
  },
123
+ ...(ENABLE_BUSINESS_FEATURES
124
+ ? [
125
+ {
126
+ icon: Map,
127
+ keywords: ['subscription', 'plan', 'upgrade', 'pricing'],
128
+ label: 'Subscription Plans',
129
+ labelKey: 'tab.plans',
130
+ labelNamespace: 'subscription' as const,
131
+ path: '/settings/plans',
132
+ subPath: 'plans',
133
+ },
134
+ {
135
+ icon: Coins,
136
+ keywords: ['funds', 'balance', 'credit', 'money'],
137
+ label: 'Funds',
138
+ labelKey: 'tab.funds',
139
+ labelNamespace: 'subscription' as const,
140
+ path: '/settings/funds',
141
+ subPath: 'funds',
142
+ },
143
+ {
144
+ icon: PieChart,
145
+ keywords: ['usage', 'statistics', 'consumption', 'quota'],
146
+ label: 'Usage',
147
+ labelKey: 'tab.usage',
148
+ labelNamespace: 'subscription' as const,
149
+ path: '/settings/usage',
150
+ subPath: 'usage',
151
+ },
152
+ {
153
+ icon: CreditCard,
154
+ keywords: ['billing', 'payment', 'invoice', 'transaction'],
155
+ label: 'Billing',
156
+ labelKey: 'tab.billing',
157
+ labelNamespace: 'subscription' as const,
158
+ path: '/settings/billing',
159
+ subPath: 'billing',
160
+ },
161
+ {
162
+ icon: Gift,
163
+ keywords: ['referral', 'rewards', 'invite', 'bonus'],
164
+ label: 'Referral Rewards',
165
+ labelKey: 'tab.referral',
166
+ labelNamespace: 'subscription' as const,
167
+ path: '/settings/referral',
168
+ subPath: 'referral',
169
+ },
170
+ ]
171
+ : []),
117
172
  ],
118
173
  };
119
174
 
@@ -1,3 +1,4 @@
1
+ import { ENABLE_BUSINESS_FEATURES } from '@lobechat/business-const';
1
2
  import { AgentRuntimeErrorType, type ILobeAgentRuntimeErrorType } from '@lobechat/model-runtime';
2
3
  import { ChatErrorType, type ChatMessageError, type ErrorType } from '@lobechat/types';
3
4
  import { type IPluginErrorType } from '@lobehub/chat-plugin-sdk';
@@ -6,6 +7,7 @@ import dynamic from 'next/dynamic';
6
7
  import { memo, useMemo } from 'react';
7
8
  import { useTranslation } from 'react-i18next';
8
9
 
10
+ import useRenderBusinessChatErrorMessageExtra from '@/business/client/hooks/useRenderBusinessChatErrorMessageExtra';
9
11
  import ErrorContent from '@/features/Conversation/ChatItem/components/ErrorContent';
10
12
  import { useProviderName } from '@/hooks/useProviderName';
11
13
 
@@ -103,7 +105,11 @@ interface ErrorExtraProps {
103
105
  }
104
106
 
105
107
  const ErrorMessageExtra = memo<ErrorExtraProps>(({ error: alertError, data }) => {
106
- const error = data.error as ChatMessageError;
108
+ const error = data.error;
109
+ const businessChatErrorMessageExtra = useRenderBusinessChatErrorMessageExtra(error, data.id);
110
+ if (ENABLE_BUSINESS_FEATURES && businessChatErrorMessageExtra)
111
+ return businessChatErrorMessageExtra;
112
+
107
113
  if (!error?.type) return;
108
114
 
109
115
  switch (error.type) {
@@ -2,6 +2,33 @@ import { IoRedisRedisProvider } from './redis';
2
2
  import { type BaseRedisProvider, type RedisConfig } from './types';
3
3
  import { UpstashRedisProvider } from './upstash';
4
4
 
5
+ /**
6
+ * Create a Redis provider instance based on config
7
+ *
8
+ * @param config - Redis config
9
+ * @param prefix - Optional custom prefix to override config.prefix
10
+ * @returns Provider instance or null if disabled/unsupported
11
+ */
12
+ const createProvider = (config: RedisConfig, prefix?: string): BaseRedisProvider | null => {
13
+ if (!config.enabled) return null;
14
+
15
+ const actualPrefix = prefix ?? config.prefix;
16
+
17
+ if (config.provider === 'redis') {
18
+ return new IoRedisRedisProvider({ ...config, prefix: actualPrefix });
19
+ }
20
+
21
+ if (config.provider === 'upstash') {
22
+ return new UpstashRedisProvider({
23
+ prefix: actualPrefix,
24
+ token: config.token,
25
+ url: config.url,
26
+ });
27
+ }
28
+
29
+ return null;
30
+ };
31
+
5
32
  class RedisManager {
6
33
  private static instance: BaseRedisProvider | null = null;
7
34
  // NOTICE: initPromise keeps concurrent initialize() calls sharing the same in-flight setup,
@@ -13,25 +40,13 @@ class RedisManager {
13
40
  if (RedisManager.initPromise) return RedisManager.initPromise;
14
41
 
15
42
  RedisManager.initPromise = (async () => {
16
- if (!config.enabled) {
43
+ const provider = createProvider(config);
44
+
45
+ if (!provider) {
17
46
  RedisManager.instance = null;
18
47
  return null;
19
48
  }
20
49
 
21
- let provider: BaseRedisProvider;
22
-
23
- if (config.provider === 'redis') {
24
- provider = new IoRedisRedisProvider(config);
25
- } else if (config.provider === 'upstash') {
26
- provider = new UpstashRedisProvider({
27
- prefix: config.prefix,
28
- token: config.token,
29
- url: config.url,
30
- });
31
- } else {
32
- throw new Error(`Unsupported redis provider: ${String((config as any).provider)}`);
33
- }
34
-
35
50
  await provider.initialize();
36
51
  RedisManager.instance = provider;
37
52
 
@@ -58,3 +73,24 @@ export const initializeRedis = (config: RedisConfig) => RedisManager.initialize(
58
73
  export const resetRedisClient = () => RedisManager.reset();
59
74
  export const isRedisEnabled = (config: RedisConfig) => config.enabled;
60
75
  export { RedisManager };
76
+
77
+ /**
78
+ * Create a Redis client with custom prefix
79
+ *
80
+ * Unlike initializeRedis, this creates an independent client
81
+ * that doesn't share the singleton instance.
82
+ *
83
+ * @param config - Redis config
84
+ * @param prefix - Custom prefix for all keys (e.g., 'aiGeneration')
85
+ * @returns Redis client or null if Redis is disabled
86
+ */
87
+ export const createRedisWithPrefix = async (
88
+ config: RedisConfig,
89
+ prefix: string,
90
+ ): Promise<BaseRedisProvider | null> => {
91
+ const provider = createProvider(config, prefix);
92
+ if (!provider) return null;
93
+
94
+ await provider.initialize();
95
+ return provider;
96
+ };
@@ -133,8 +133,8 @@ export default {
133
133
  'cmdk.navigate': 'Navigate',
134
134
  'cmdk.newAgent': 'Create New Agent',
135
135
  'cmdk.newAgentTeam': 'Create New Group',
136
- 'cmdk.newLibrary': 'New Library',
137
- 'cmdk.newPage': 'New Page',
136
+ 'cmdk.newLibrary': 'Create New Library',
137
+ 'cmdk.newPage': 'Create New Page',
138
138
  'cmdk.newTopic': 'New topic in current Agent',
139
139
  'cmdk.noResults': 'No results found',
140
140
  'cmdk.openSettings': 'Open Settings',