@lobehub/lobehub 2.0.0-next.223 → 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.
@@ -6,9 +6,14 @@ permissions:
6
6
  actions: write
7
7
  contents: read
8
8
 
9
+ concurrency:
10
+ group: ${{ github.workflow }}-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
9
13
  jobs:
10
14
  # Check for duplicate runs
11
- pre_job:
15
+ check-duplicate-run:
16
+ name: Check Duplicate Run
12
17
  runs-on: ubuntu-latest
13
18
  outputs:
14
19
  should_skip: ${{ steps.skip_check.outputs.should_skip }}
@@ -16,69 +21,18 @@ jobs:
16
21
  - id: skip_check
17
22
  uses: fkirc/skip-duplicate-actions@v5
18
23
  with:
19
- concurrent_skipping: 'same_content_newer'
20
- skip_after_successful_duplicate: 'true'
24
+ concurrent_skipping: "same_content_newer"
25
+ skip_after_successful_duplicate: "true"
21
26
  do_not_skip: '["workflow_dispatch", "schedule"]'
22
27
 
23
- # Package tests - using each package's own test script
24
- test-intenral-packages:
25
- needs: pre_job
26
- if: needs.pre_job.outputs.should_skip != 'true'
27
- runs-on: ubuntu-latest
28
- strategy:
29
- matrix:
30
- package:
31
- - file-loaders
32
- - prompts
33
- - model-runtime
34
- - web-crawler
35
- - electron-server-ipc
36
- - utils
37
- - python-interpreter
38
- - context-engine
39
- - agent-runtime
40
- - conversation-flow
41
- - ssrf-safe-fetch
42
- - memory-user-memory
43
-
44
- name: Test package ${{ matrix.package }}
45
-
46
- steps:
47
- - uses: actions/checkout@v6
48
-
49
- - name: Setup Node.js
50
- uses: actions/setup-node@v6
51
- with:
52
- node-version: 24.11.1
53
- package-manager-cache: false
54
-
55
- - name: Install bun
56
- uses: oven-sh/setup-bun@v2
57
- with:
58
- bun-version: ${{ secrets.BUN_VERSION }}
59
-
60
- - name: Install deps
61
- run: bun i
62
-
63
- - name: Test ${{ matrix.package }} package with coverage
64
- run: bun run --filter @lobechat/${{ matrix.package }} test:coverage
65
-
66
- - name: Upload ${{ matrix.package }} coverage to Codecov
67
- uses: codecov/codecov-action@v5
68
- with:
69
- token: ${{ secrets.CODECOV_TOKEN }}
70
- files: ./packages/${{ matrix.package }}/coverage/lcov.info
71
- flags: packages/${{ matrix.package }}
72
-
28
+ # Package tests - all packages in single job to save runner resources
73
29
  test-packages:
74
- needs: pre_job
75
- if: needs.pre_job.outputs.should_skip != 'true'
30
+ needs: check-duplicate-run
31
+ if: needs.check-duplicate-run.outputs.should_skip != 'true'
76
32
  runs-on: ubuntu-latest
77
- strategy:
78
- matrix:
79
- package: [model-bank]
80
-
81
- name: Test package ${{ matrix.package }}
33
+ name: Test Packages
34
+ env:
35
+ PACKAGES: "@lobechat/file-loaders @lobechat/prompts @lobechat/model-runtime @lobechat/web-crawler @lobechat/electron-server-ipc @lobechat/utils @lobechat/python-interpreter @lobechat/context-engine @lobechat/agent-runtime @lobechat/conversation-flow @lobechat/ssrf-safe-fetch @lobechat/memory-user-memory model-bank"
82
36
 
83
37
  steps:
84
38
  - uses: actions/checkout@v6
@@ -92,25 +46,40 @@ jobs:
92
46
  - name: Install bun
93
47
  uses: oven-sh/setup-bun@v2
94
48
  with:
95
- bun-version: latest
49
+ bun-version: ${{ secrets.BUN_VERSION }}
96
50
 
97
51
  - name: Install deps
98
52
  run: bun i
99
53
 
100
- - name: Test ${{ matrix.package }} package with coverage
101
- run: bun run --filter ${{ matrix.package }} test:coverage
102
-
103
- - name: Upload ${{ matrix.package }} coverage to Codecov
104
- uses: codecov/codecov-action@v5
105
- with:
106
- token: ${{ secrets.CODECOV_TOKEN }}
107
- files: ./packages/${{ matrix.package }}/coverage/lcov.info
108
- flags: packages/${{ matrix.package }}
54
+ - name: Test packages with coverage
55
+ run: |
56
+ for package in $PACKAGES; do
57
+ echo "::group::Testing $package"
58
+ bun run --filter $package test:coverage
59
+ echo "::endgroup::"
60
+ done
61
+
62
+ - name: Upload coverage to Codecov
63
+ if: always()
64
+ run: |
65
+ curl -Os https://cli.codecov.io/latest/linux/codecov
66
+ chmod +x codecov
67
+ for package in $PACKAGES; do
68
+ dir="${package#@lobechat/}"
69
+ if [ -f "./packages/$dir/coverage/lcov.info" ]; then
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
76
+ fi
77
+ done
109
78
 
110
79
  # App tests
111
80
  test-website:
112
- needs: pre_job
113
- if: needs.pre_job.outputs.should_skip != 'true'
81
+ needs: check-duplicate-run
82
+ if: needs.check-duplicate-run.outputs.should_skip != 'true'
114
83
  name: Test Website
115
84
 
116
85
  runs-on: ubuntu-latest
@@ -143,8 +112,8 @@ jobs:
143
112
  flags: app
144
113
 
145
114
  test-desktop:
146
- needs: pre_job
147
- if: needs.pre_job.outputs.should_skip != 'true'
115
+ needs: check-duplicate-run
116
+ if: needs.check-duplicate-run.outputs.should_skip != 'true'
148
117
  name: Test Desktop App
149
118
 
150
119
  runs-on: ubuntu-latest
@@ -185,8 +154,8 @@ jobs:
185
154
  flags: desktop
186
155
 
187
156
  test-databsae:
188
- needs: pre_job
189
- if: needs.pre_job.outputs.should_skip != 'true'
157
+ needs: check-duplicate-run
158
+ if: needs.check-duplicate-run.outputs.should_skip != 'true'
190
159
  name: Test Database
191
160
 
192
161
  runs-on: ubuntu-latest
@@ -199,7 +168,6 @@ jobs:
199
168
  options: >-
200
169
  --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
201
170
 
202
-
203
171
  ports:
204
172
  - 5432:5432
205
173
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
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
+
30
+ ## [Version 2.0.0-next.224](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.223...v2.0.0-next.224)
31
+
32
+ <sup>Released on **2026-01-06**</sup>
33
+
34
+ #### ♻ Code Refactoring
35
+
36
+ - **router**: Replace client-side rendering with dynamic import for DesktopClientRouter.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### Code refactoring
44
+
45
+ - **router**: Replace client-side rendering with dynamic import for DesktopClientRouter, closes [#11276](https://github.com/lobehub/lobe-chat/issues/11276) ([f50305b](https://github.com/lobehub/lobe-chat/commit/f50305b))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ## [Version 2.0.0-next.223](https://github.com/lobehub/lobe-chat/compare/v2.0.0-next.222...v2.0.0-next.223)
6
56
 
7
57
  <sup>Released on **2026-01-06**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,14 @@
1
1
  [
2
+ {
3
+ "children": {},
4
+ "date": "2026-01-06",
5
+ "version": "2.0.0-next.225"
6
+ },
7
+ {
8
+ "children": {},
9
+ "date": "2026-01-06",
10
+ "version": "2.0.0-next.224"
11
+ },
2
12
  {
3
13
  "children": {
4
14
  "fixes": [
@@ -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.223",
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;
@@ -1,20 +1,16 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useState } from 'react';
3
+ import dynamic from 'next/dynamic';
4
4
 
5
- import DesktopClientRouter from './DesktopClientRouter';
5
+ import Loading from '@/components/Loading/BrandTextLoading';
6
+
7
+ const DesktopRouterClient = dynamic(() => import('./DesktopClientRouter'), {
8
+ loading: () => <Loading debugId="DesktopRouter" />,
9
+ ssr: false,
10
+ });
6
11
 
7
- const useIsClient = () => {
8
- const [isClient, setIsClient] = useState(false);
9
- useEffect(() => {
10
- setIsClient(true);
11
- }, []);
12
- return isClient;
13
- };
14
12
  const DesktopRouter = () => {
15
- const isClient = useIsClient();
16
- if (!isClient) return null;
17
- return <DesktopClientRouter />;
13
+ return <DesktopRouterClient />;
18
14
  };
19
15
 
20
16
  export default DesktopRouter;
@@ -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',