@lobehub/lobehub 2.0.0-next.53 → 2.0.0-next.55

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 (165) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/changelog/v1.json +18 -0
  3. package/locales/ar/common.json +1 -0
  4. package/locales/ar/file.json +85 -2
  5. package/locales/bg-BG/common.json +1 -0
  6. package/locales/bg-BG/file.json +85 -2
  7. package/locales/de-DE/common.json +1 -0
  8. package/locales/de-DE/file.json +85 -2
  9. package/locales/en-US/common.json +1 -0
  10. package/locales/en-US/file.json +85 -2
  11. package/locales/es-ES/common.json +1 -0
  12. package/locales/es-ES/file.json +85 -2
  13. package/locales/fa-IR/common.json +1 -0
  14. package/locales/fa-IR/file.json +85 -2
  15. package/locales/fr-FR/common.json +1 -0
  16. package/locales/fr-FR/file.json +85 -2
  17. package/locales/it-IT/common.json +1 -0
  18. package/locales/it-IT/file.json +85 -2
  19. package/locales/ja-JP/common.json +1 -0
  20. package/locales/ja-JP/file.json +85 -2
  21. package/locales/ko-KR/common.json +1 -0
  22. package/locales/ko-KR/file.json +85 -2
  23. package/locales/nl-NL/common.json +1 -0
  24. package/locales/nl-NL/file.json +85 -2
  25. package/locales/pl-PL/common.json +1 -0
  26. package/locales/pl-PL/file.json +85 -2
  27. package/locales/pt-BR/common.json +1 -0
  28. package/locales/pt-BR/file.json +85 -2
  29. package/locales/ru-RU/common.json +1 -0
  30. package/locales/ru-RU/file.json +85 -2
  31. package/locales/tr-TR/common.json +1 -0
  32. package/locales/tr-TR/file.json +85 -2
  33. package/locales/vi-VN/common.json +1 -0
  34. package/locales/vi-VN/file.json +85 -2
  35. package/locales/zh-CN/common.json +1 -0
  36. package/locales/zh-CN/file.json +85 -2
  37. package/locales/zh-TW/common.json +1 -0
  38. package/locales/zh-TW/file.json +85 -2
  39. package/package.json +1 -1
  40. package/packages/database/src/models/__tests__/file.test.ts +94 -29
  41. package/packages/database/src/models/file.ts +15 -4
  42. package/packages/database/src/repositories/knowledge/index.test.ts +300 -0
  43. package/packages/database/src/repositories/knowledge/index.ts +420 -0
  44. package/packages/model-bank/src/aiModels/aihubmix.ts +1 -0
  45. package/packages/model-bank/src/aiModels/google.ts +9 -5
  46. package/packages/model-bank/src/aiModels/openai.ts +2 -35
  47. package/packages/model-bank/src/aiModels/openrouter.ts +1 -0
  48. package/packages/model-bank/src/aiModels/vertexai.ts +2 -0
  49. package/packages/model-bank/src/types/aiModel.ts +15 -2
  50. package/packages/model-runtime/src/core/usageConverters/index.ts +1 -0
  51. package/packages/model-runtime/src/core/usageConverters/utils/resolveImageSinglePrice.ts +34 -0
  52. package/packages/types/src/document/index.ts +14 -2
  53. package/packages/types/src/files/index.ts +2 -0
  54. package/packages/types/src/files/list.ts +10 -0
  55. package/packages/types/src/llm.ts +1 -1
  56. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ModelSelect/ImageModelItem.tsx +93 -0
  57. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/{ModelSelect.tsx → ModelSelect/index.tsx} +17 -2
  58. package/src/app/[variants]/(main)/knowledge/KnowledgeRouter.tsx +2 -1
  59. package/src/app/[variants]/(main)/knowledge/components/KnowledgeBaseItem/index.tsx +0 -2
  60. package/src/app/[variants]/(main)/knowledge/hooks/useFileCategory.ts +6 -3
  61. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeBaseDetail/index.tsx +2 -2
  62. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeBaseDetail/menu/{MenuItems.tsx → CategoryMenu.tsx} +3 -3
  63. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeBaseDetail/menu/Menu.tsx +2 -2
  64. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeHome/index.tsx +40 -18
  65. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeHome/layout/Container.tsx +1 -1
  66. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeHome/menu/CategoryMenu.tsx +148 -0
  67. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeHome/menu/KnowledgeBase.tsx +20 -7
  68. package/src/components/FileIcon/index.tsx +3 -1
  69. package/src/features/ChatInput/ActionBar/Knowledge/index.tsx +2 -2
  70. package/src/features/Conversation/Messages/Assistant/index.tsx +7 -1
  71. package/src/features/FileSidePanel/index.tsx +1 -1
  72. package/src/features/KnowledgeBaseModal/AssignKnowledgeBase/Item/MasonryItem.tsx +80 -0
  73. package/src/features/KnowledgeBaseModal/AssignKnowledgeBase/Item/MasonryItemWrapper.tsx +27 -0
  74. package/src/features/KnowledgeBaseModal/AssignKnowledgeBase/List.tsx +104 -23
  75. package/src/features/KnowledgeBaseModal/AssignKnowledgeBase/MasonrySkeleton.tsx +62 -0
  76. package/src/features/KnowledgeBaseModal/AssignKnowledgeBase/index.tsx +3 -2
  77. package/src/features/KnowledgeBaseModal/CreateNew/CreateForm.tsx +1 -1
  78. package/src/features/KnowledgeManager/DocumentExplorer/DocumentActions.tsx +111 -0
  79. package/src/features/KnowledgeManager/DocumentExplorer/DocumentEditor.tsx +723 -0
  80. package/src/features/KnowledgeManager/DocumentExplorer/DocumentEditorPlaceholder.tsx +169 -0
  81. package/src/features/KnowledgeManager/DocumentExplorer/DocumentListItem.tsx +148 -0
  82. package/src/features/KnowledgeManager/DocumentExplorer/DocumentListSkeleton.tsx +39 -0
  83. package/src/features/KnowledgeManager/DocumentExplorer/NoteEditorModal.tsx +348 -0
  84. package/src/features/KnowledgeManager/DocumentExplorer/RenamePopover.tsx +163 -0
  85. package/src/features/KnowledgeManager/DocumentExplorer/index.tsx +318 -0
  86. package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/FileListItem/index.tsx +48 -9
  87. package/src/features/KnowledgeManager/FileExplorer/MasonryFileItem/DefaultFileItem.tsx +149 -0
  88. package/src/features/KnowledgeManager/FileExplorer/MasonryFileItem/ImageFileItem.tsx +245 -0
  89. package/src/features/KnowledgeManager/FileExplorer/MasonryFileItem/MarkdownFileItem.tsx +232 -0
  90. package/src/features/KnowledgeManager/FileExplorer/MasonryFileItem/NoteFileItem.tsx +230 -0
  91. package/src/features/KnowledgeManager/FileExplorer/MasonryFileItem/index.tsx +398 -0
  92. package/src/features/KnowledgeManager/FileExplorer/ToolBar/ViewSwitcher.tsx +45 -0
  93. package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/index.tsx +68 -16
  94. package/src/features/KnowledgeManager/Header/AddButton.tsx +107 -0
  95. package/src/features/KnowledgeManager/Header/NewNoteButton.tsx +33 -0
  96. package/src/features/{FileManager → KnowledgeManager}/Header/index.tsx +3 -9
  97. package/src/features/KnowledgeManager/Home/RecentDocumentCard.tsx +116 -0
  98. package/src/features/KnowledgeManager/Home/RecentDocuments.tsx +77 -0
  99. package/src/features/KnowledgeManager/Home/RecentFileCard.tsx +121 -0
  100. package/src/features/KnowledgeManager/Home/RecentFiles.tsx +73 -0
  101. package/src/features/KnowledgeManager/Home/RecentFilesSkeleton.tsx +83 -0
  102. package/src/features/KnowledgeManager/Home/UploadEntries.tsx +208 -0
  103. package/src/features/KnowledgeManager/Home/index.tsx +221 -0
  104. package/src/features/KnowledgeManager/index.tsx +75 -0
  105. package/src/features/Portal/FilePreview/Body/index.tsx +1 -1
  106. package/src/features/Portal/FilePreview/Header.tsx +1 -1
  107. package/src/locales/default/common.ts +1 -0
  108. package/src/locales/default/file.ts +85 -2
  109. package/src/locales/default/tool.ts +8 -0
  110. package/src/server/routers/lambda/__tests__/file.test.ts +85 -6
  111. package/src/server/routers/lambda/document.ts +57 -0
  112. package/src/server/routers/lambda/file.ts +72 -0
  113. package/src/server/routers/lambda/knowledge.ts +94 -0
  114. package/src/server/services/document/index.ts +103 -0
  115. package/src/services/document/index.ts +44 -0
  116. package/src/services/file/index.ts +5 -3
  117. package/src/store/aiInfra/slices/aiProvider/__tests__/action.test.ts +125 -229
  118. package/src/store/aiInfra/slices/aiProvider/action.ts +113 -33
  119. package/src/store/chat/slices/builtinTool/actions/localSystem.ts +1 -1
  120. package/src/store/file/initialState.ts +6 -1
  121. package/src/store/file/slices/chat/action.ts +3 -3
  122. package/src/store/file/slices/document/action.ts +359 -0
  123. package/src/store/file/slices/document/index.ts +3 -0
  124. package/src/store/file/slices/document/initialState.ts +22 -0
  125. package/src/store/file/slices/document/selectors.ts +25 -0
  126. package/src/store/file/slices/fileManager/action.test.ts +16 -9
  127. package/src/store/file/slices/fileManager/action.ts +11 -11
  128. package/src/store/file/store.ts +3 -0
  129. package/src/store/global/initialState.ts +3 -1
  130. package/src/tools/interventions.ts +3 -5
  131. package/src/tools/local-system/Intervention/MoveLocalFiles/MoveFileItem.tsx +56 -0
  132. package/src/tools/local-system/Intervention/MoveLocalFiles/index.tsx +26 -0
  133. package/src/tools/local-system/Intervention/RunCommand/index.tsx +1 -2
  134. package/src/tools/local-system/Intervention/index.ts +11 -0
  135. package/src/tools/local-system/Render/MoveLocalFiles/MoveFileItem.tsx +56 -0
  136. package/src/tools/local-system/Render/MoveLocalFiles/index.tsx +26 -0
  137. package/src/tools/local-system/Render/index.ts +21 -0
  138. package/src/tools/renders.ts +6 -24
  139. package/src/tools/web-browsing/Render/index.ts +13 -0
  140. package/src/app/[variants]/(main)/knowledge/routes/KnowledgeHome/menu/FileMenu.tsx +0 -75
  141. package/src/features/FileManager/FileList/MasonryFileItem/index.tsx +0 -582
  142. package/src/features/FileManager/index.tsx +0 -36
  143. /package/src/features/{FileManager/FileList/ToolBar → KnowledgeBaseModal/AssignKnowledgeBase}/ViewSwitcher.tsx +0 -0
  144. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/ChunkList/ChunkItem.tsx +0 -0
  145. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/ChunkList/index.tsx +0 -0
  146. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/Content.tsx +0 -0
  147. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/Loading/index.tsx +0 -0
  148. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/SimilaritySearchList/Item.tsx +0 -0
  149. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/SimilaritySearchList/index.tsx +0 -0
  150. /package/src/features/{FileManager → KnowledgeManager}/ChunkDrawer/index.tsx +0 -0
  151. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/EmptyStatus.tsx +0 -0
  152. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/FileListItem/ChunkTag.tsx +0 -0
  153. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/FileListItem/DropdownMenu.tsx +0 -0
  154. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/FileSkeleton.tsx +0 -0
  155. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/MasonryFileItem/MasonryItemWrapper.tsx +0 -0
  156. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/MasonrySkeleton.tsx +0 -0
  157. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/ToolBar/Config.tsx +0 -0
  158. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/ToolBar/MultiSelectActions.tsx +0 -0
  159. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/ToolBar/index.tsx +0 -0
  160. /package/src/features/{FileManager/FileList → KnowledgeManager/FileExplorer}/useCheckTaskStatus.ts +0 -0
  161. /package/src/features/{FileManager → KnowledgeManager}/Header/FilesSearchBar.tsx +0 -0
  162. /package/src/features/{FileManager → KnowledgeManager}/Header/TogglePanelButton.tsx +0 -0
  163. /package/src/features/{FileManager → KnowledgeManager}/Header/UploadFileButton.tsx +0 -0
  164. /package/src/features/{FileManager → KnowledgeManager}/UploadDock/Item.tsx +0 -0
  165. /package/src/features/{FileManager → KnowledgeManager}/UploadDock/index.tsx +0 -0
@@ -0,0 +1,44 @@
1
+ import { DocumentItem } from '@lobechat/database/schemas';
2
+
3
+ import { lambdaClient } from '@/libs/trpc/client';
4
+
5
+ export interface CreateDocumentParams {
6
+ content?: string;
7
+ editorData: string;
8
+ fileType?: string;
9
+ knowledgeBaseId?: string;
10
+ metadata?: Record<string, any>;
11
+ title: string;
12
+ }
13
+
14
+ export interface UpdateDocumentParams {
15
+ content?: string;
16
+ editorData?: string;
17
+ id: string;
18
+ metadata?: Record<string, any>;
19
+ title?: string;
20
+ }
21
+
22
+ export class DocumentService {
23
+ async createDocument(params: CreateDocumentParams): Promise<DocumentItem> {
24
+ return lambdaClient.document.createDocument.mutate(params);
25
+ }
26
+
27
+ async queryDocuments(): Promise<DocumentItem[]> {
28
+ return lambdaClient.document.queryDocuments.query();
29
+ }
30
+
31
+ async getDocumentById(id: string): Promise<DocumentItem | undefined> {
32
+ return lambdaClient.document.getDocumentById.query({ id });
33
+ }
34
+
35
+ async deleteDocument(id: string): Promise<void> {
36
+ await lambdaClient.document.deleteDocument.mutate({ id });
37
+ }
38
+
39
+ async updateDocument(params: UpdateDocumentParams): Promise<void> {
40
+ await lambdaClient.document.updateDocument.mutate(params);
41
+ }
42
+ }
43
+
44
+ export const documentService = new DocumentService();
@@ -42,11 +42,13 @@ export class FileService {
42
42
  await lambdaClient.file.removeAllFiles.mutate();
43
43
  };
44
44
 
45
- getFiles = async (params: QueryFileListParams) => {
46
- return lambdaClient.file.getFiles.query(params as QueryFileListSchemaType);
45
+ // V2.0 Migrate from getFiles to getKnowledgeItems
46
+ getKnowledgeItems = async (params: QueryFileListParams) => {
47
+ return lambdaClient.file.getKnowledgeItems.query(params as QueryFileListSchemaType);
47
48
  };
48
49
 
49
- getFileItem = async (id: string) => {
50
+ // V2.0 Migrate from getFileItem to getKnowledgeItem
51
+ getKnowledgeItem = async (id: string) => {
50
52
  return lambdaClient.file.getFileItemById.query({ id });
51
53
  };
52
54
 
@@ -1,276 +1,172 @@
1
1
  import * as runtimeModule from '@lobechat/model-runtime';
2
- import type { EnabledAiModel, ModelAbilities } from 'model-bank';
3
- import { describe, expect, it, vi } from 'vitest';
4
-
5
- import { getModelListByType } from '../action';
6
-
7
- // Test fixtures
8
- const createChatModel = (
9
- id: string,
10
- providerId: string,
11
- overrides: Partial<EnabledAiModel> = {},
12
- ): EnabledAiModel => ({
13
- id,
14
- providerId,
2
+ import type { AIImageModelCard, EnabledAiModel, ModelParamsSchema } from 'model-bank';
3
+ import { afterEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import {
6
+ getChatModelList,
7
+ getImageModelList,
8
+ normalizeChatModel,
9
+ normalizeImageModel,
10
+ } from '../action';
11
+
12
+ const createChatModel = (overrides: Partial<EnabledAiModel> = {}): EnabledAiModel => ({
13
+ abilities: overrides.abilities ?? { functionCall: true },
14
+ contextWindowTokens: overrides.contextWindowTokens ?? 8192,
15
+ displayName: overrides.displayName ?? 'Chat Model',
16
+ enabled: overrides.enabled ?? true,
17
+ id: overrides.id ?? 'chat-model',
18
+ providerId: overrides.providerId ?? 'openai',
15
19
  type: 'chat',
16
- abilities: { functionCall: true, files: true } satisfies ModelAbilities,
17
- contextWindowTokens: 8192,
18
- displayName: `${id} model`,
19
- enabled: true,
20
20
  ...overrides,
21
21
  });
22
22
 
23
- const createImageModel = (
24
- id: string,
25
- providerId: string,
26
- overrides: Partial<EnabledAiModel> = {},
27
- ): EnabledAiModel => ({
28
- id,
29
- providerId,
23
+ type ImageEnabledModel = EnabledAiModel & AIImageModelCard;
24
+
25
+ const createImageModel = (overrides: Partial<ImageEnabledModel> = {}): ImageEnabledModel => ({
26
+ abilities: overrides.abilities ?? {},
27
+ contextWindowTokens: overrides.contextWindowTokens,
28
+ displayName: overrides.displayName ?? 'Image Model',
29
+ enabled: overrides.enabled ?? true,
30
+ id: overrides.id ?? 'image-model',
31
+ providerId: overrides.providerId ?? 'openai',
30
32
  type: 'image',
31
- abilities: {} satisfies ModelAbilities,
32
- displayName: `${id} model`,
33
- enabled: true,
34
33
  ...overrides,
35
34
  });
36
35
 
37
- // Core test data
38
- const mockChatModels = [
39
- createChatModel('gpt-4', 'openai', {
40
- displayName: 'GPT-4',
41
- abilities: { functionCall: true, files: true } satisfies ModelAbilities,
42
- }),
43
- createChatModel('gpt-3.5-turbo', 'openai', {
44
- displayName: 'GPT-3.5 Turbo',
45
- abilities: { functionCall: true } satisfies ModelAbilities,
46
- contextWindowTokens: 4096,
47
- }),
48
- createChatModel('claude-3-opus', 'anthropic', {
49
- displayName: 'Claude 3 Opus',
50
- abilities: { functionCall: false, files: true } satisfies ModelAbilities,
51
- contextWindowTokens: 200000,
52
- }),
53
- ];
54
-
55
- const mockImageModels = [
56
- createImageModel('dall-e-3', 'openai', {
57
- displayName: 'DALL-E 3',
58
- parameters: {
59
- prompt: { default: '' },
60
- size: { default: '1024x1024', enum: ['512x512', '1024x1024', '1536x1536'] },
61
- },
62
- }),
63
- createImageModel('midjourney', 'midjourney', {
64
- displayName: 'Midjourney',
65
- }),
66
- ];
67
-
68
- const allModels = [...mockChatModels, ...mockImageModels];
69
-
70
- describe('getModelListByType', () => {
71
- describe('Core Functionality', () => {
72
- it('should filter models by providerId and type correctly', async () => {
73
- const result = await getModelListByType(allModels, 'openai', 'chat');
74
-
75
- expect(result).toHaveLength(2);
76
- expect(result.map((m) => m.id)).toEqual(['gpt-4', 'gpt-3.5-turbo']);
77
- });
78
-
79
- it('should return correct model structure for chat models', async () => {
80
- const result = await getModelListByType(allModels, 'openai', 'chat');
36
+ describe('aiProvider action helpers', () => {
37
+ afterEach(() => {
38
+ vi.restoreAllMocks();
39
+ });
81
40
 
82
- expect(result[0]).toEqual({
83
- abilities: { functionCall: true, files: true },
84
- contextWindowTokens: 8192,
85
- displayName: 'GPT-4',
86
- id: 'gpt-4',
41
+ describe('normalizeChatModel', () => {
42
+ it('fills missing optional fields with safe defaults', () => {
43
+ const model = createChatModel({
44
+ abilities: undefined,
45
+ contextWindowTokens: undefined,
46
+ displayName: undefined,
87
47
  });
88
- });
89
48
 
90
- it('should include parameters field for image models', async () => {
91
- const result = await getModelListByType(allModels, 'openai', 'image');
49
+ const result = normalizeChatModel(model);
92
50
 
93
- expect(result[0]).toEqual({
51
+ expect(result).toEqual({
94
52
  abilities: {},
95
53
  contextWindowTokens: undefined,
96
- displayName: 'DALL-E 3',
97
- id: 'dall-e-3',
98
- parameters: {
99
- prompt: { default: '' },
100
- size: { default: '1024x1024', enum: ['512x512', '1024x1024', '1536x1536'] },
101
- },
102
- });
103
- });
104
-
105
- it('should exclude parameters field from chat models', async () => {
106
- const result = await getModelListByType(mockChatModels, 'openai', 'chat');
107
-
108
- result.forEach((model) => {
109
- expect(model).not.toHaveProperty('parameters');
54
+ displayName: '',
55
+ id: 'chat-model',
110
56
  });
111
57
  });
112
-
113
- it('should remove duplicate model IDs', async () => {
114
- const duplicateModels = [
115
- createChatModel('gpt-4', 'openai', {
116
- displayName: 'GPT-4 Version 1',
117
- abilities: { functionCall: true } satisfies ModelAbilities,
118
- }),
119
- createChatModel('gpt-4', 'openai', {
120
- displayName: 'GPT-4 Version 2',
121
- abilities: { functionCall: false } satisfies ModelAbilities,
122
- }),
123
- ];
124
-
125
- const result = await getModelListByType(duplicateModels, 'openai', 'chat');
126
-
127
- expect(result).toHaveLength(1);
128
- expect(result[0].displayName).toBe('GPT-4 Version 1');
129
- });
130
58
  });
131
59
 
132
- describe('Edge Cases and Error Handling', () => {
133
- it('should handle empty inputs gracefully', async () => {
134
- const emptyResult = await getModelListByType([], 'openai', 'chat');
135
- expect(emptyResult).toEqual([]);
136
-
137
- const noMatchingProvider = await getModelListByType(allModels, 'nonexistent', 'chat');
138
- expect(noMatchingProvider).toEqual([]);
139
-
140
- const noMatchingType = await getModelListByType(allModels, 'openai', 'nonexistent');
141
- expect(noMatchingType).toEqual([]);
142
- });
143
-
144
- it('should handle missing optional properties', async () => {
145
- const modelWithMissingProps = createChatModel('test-model', 'test', {
146
- displayName: undefined,
147
- abilities: undefined,
148
- contextWindowTokens: undefined,
149
- });
150
-
151
- const result = await getModelListByType([modelWithMissingProps], 'test', 'chat');
152
-
153
- expect(result[0].displayName).toBe('');
154
- expect(result[0].abilities).toEqual({});
155
- expect(result[0].contextWindowTokens).toBeUndefined();
156
- });
157
-
158
- it('should preserve complex model properties', async () => {
159
- const complexModel = createChatModel('complex-model', 'test', {
160
- displayName: 'Complex Model with All Properties',
161
- abilities: {
162
- functionCall: true,
163
- files: true,
164
- vision: false,
165
- } satisfies ModelAbilities,
166
- contextWindowTokens: 128000,
60
+ describe('normalizeImageModel', () => {
61
+ it('preserves inline metadata and pricing information', async () => {
62
+ const model = createImageModel({
63
+ abilities: { vision: true },
64
+ contextWindowTokens: 4096,
65
+ displayName: 'Inline Model',
66
+ parameters: {
67
+ prompt: { default: '' },
68
+ size: { default: '1024x1024', enum: ['512x512', '1024x1024'] },
69
+ } as ModelParamsSchema,
70
+ pricing: {
71
+ units: [{ name: 'imageGeneration', rate: 0.04, strategy: 'fixed', unit: 'image' }],
72
+ },
167
73
  });
168
74
 
169
- const result = await getModelListByType([complexModel], 'test', 'chat');
75
+ const result = await normalizeImageModel(model);
170
76
 
171
- expect(result[0]).toEqual({
172
- id: 'complex-model',
173
- displayName: 'Complex Model with All Properties',
174
- abilities: {
175
- functionCall: true,
176
- files: true,
177
- vision: false,
77
+ expect(result).toMatchObject({
78
+ abilities: { vision: true },
79
+ displayName: 'Inline Model',
80
+ parameters: { size: { default: '1024x1024', enum: ['512x512', '1024x1024'] } },
81
+ pricing: {
82
+ units: [{ name: 'imageGeneration', rate: 0.04, strategy: 'fixed', unit: 'image' }],
178
83
  },
179
- contextWindowTokens: 128000,
180
84
  });
181
85
  });
182
- });
183
-
184
- describe('Image Model Parameter Handling', () => {
185
- it('should use fallback parameters for image models without parameters', async () => {
186
- vi.spyOn(runtimeModule, 'getModelPropertyWithFallback').mockResolvedValueOnce({
187
- size: '1024x1024',
188
- });
189
-
190
- const result = await getModelListByType(allModels, 'midjourney', 'image');
191
86
 
192
- expect(result[0]).toEqual({
193
- abilities: {},
194
- contextWindowTokens: undefined,
195
- displayName: 'Midjourney',
196
- id: 'midjourney',
197
- parameters: { size: '1024x1024' },
87
+ it('fetches fallback description/parameters/pricing when missing', async () => {
88
+ const fallbackSpy = vi
89
+ .spyOn(runtimeModule, 'getModelPropertyWithFallback')
90
+ .mockImplementation(async (_id, key) => {
91
+ if (key === 'parameters')
92
+ return {
93
+ prompt: { default: '' },
94
+ size: { default: '768x768', enum: ['512x512', '768x768'] },
95
+ } satisfies ModelParamsSchema;
96
+ if (key === 'pricing')
97
+ return {
98
+ units: [{ name: 'imageGeneration', rate: 0.02, strategy: 'fixed', unit: 'image' }],
99
+ };
100
+ if (key === 'description') return 'Fallback description';
101
+ return undefined;
102
+ });
103
+
104
+ const model = createImageModel({
105
+ id: 'stable-diffusion',
106
+ providerId: 'stability',
107
+ parameters: undefined,
108
+ pricing: undefined,
198
109
  });
199
- });
200
110
 
201
- it('should handle async parameter fetching for multiple models', async () => {
202
- const imageModelsWithoutParams = [
203
- createImageModel('stable-diffusion', 'stability', { displayName: 'Stable Diffusion' }),
204
- createImageModel('flux-schnell', 'fal', { displayName: 'FLUX Schnell' }),
205
- ];
111
+ const result = await normalizeImageModel(model);
206
112
 
207
- vi.spyOn(runtimeModule, 'getModelPropertyWithFallback').mockResolvedValue({
113
+ expect(result.parameters).toEqual({
208
114
  prompt: { default: '' },
209
- width: { default: 512, min: 256, max: 2048 },
210
- height: { default: 512, min: 256, max: 2048 },
115
+ size: { default: '768x768', enum: ['512x512', '768x768'] },
211
116
  });
212
-
213
- const result = await getModelListByType(imageModelsWithoutParams, 'stability', 'image');
214
-
215
- expect(result).toHaveLength(1);
216
- expect(result[0].parameters).toEqual({
217
- prompt: { default: '' },
218
- width: { default: 512, min: 256, max: 2048 },
219
- height: { default: 512, min: 256, max: 2048 },
117
+ expect(result.pricing).toEqual({
118
+ units: [{ name: 'imageGeneration', rate: 0.02, strategy: 'fixed', unit: 'image' }],
220
119
  });
221
-
222
- expect(runtimeModule.getModelPropertyWithFallback).toHaveBeenCalledWith(
223
- 'stable-diffusion',
224
- 'parameters',
225
- );
120
+ expect(result.description).toBe('Fallback description');
121
+ expect(fallbackSpy).toHaveBeenCalledWith('stable-diffusion', 'parameters', 'stability');
122
+ expect(fallbackSpy).toHaveBeenCalledWith('stable-diffusion', 'pricing', 'stability');
123
+ expect(fallbackSpy).toHaveBeenCalledWith('stable-diffusion', 'description', 'stability');
226
124
  });
125
+ });
227
126
 
228
- it('should handle failed parameter fallback gracefully', async () => {
229
- const failingModel = createImageModel('failing-model', 'test-provider', {
230
- displayName: 'Failing Model',
231
- });
127
+ describe('getChatModelList', () => {
128
+ const chatModels = [
129
+ createChatModel({ id: 'gpt-4', providerId: 'openai', displayName: 'GPT-4' }),
130
+ createChatModel({ id: 'gpt-3.5', providerId: 'openai', displayName: 'GPT-3.5' }),
131
+ createChatModel({ id: 'claude-3', providerId: 'anthropic', displayName: 'Claude 3' }),
132
+ ];
133
+
134
+ it('filters by provider and deduplicates IDs', async () => {
135
+ const duplicated = [
136
+ ...chatModels,
137
+ createChatModel({ id: 'gpt-4', providerId: 'openai', displayName: 'GPT-4 Duplicate' }),
138
+ ];
232
139
 
233
- vi.spyOn(runtimeModule, 'getModelPropertyWithFallback').mockResolvedValueOnce(undefined);
140
+ const result = await getChatModelList(duplicated, 'openai');
234
141
 
235
- const result = await getModelListByType([failingModel], 'test-provider', 'image');
142
+ expect(result).toHaveLength(2);
143
+ expect(result.map((m) => m.id)).toEqual(['gpt-4', 'gpt-3.5']);
144
+ expect(result[0].displayName).toBe('GPT-4');
145
+ });
236
146
 
237
- expect(result).toHaveLength(1);
238
- expect(result[0].id).toBe('failing-model');
239
- expect(result[0].parameters).toBeUndefined();
147
+ it('returns empty array when provider has no chat models', async () => {
148
+ const result = await getChatModelList(chatModels, 'nonexistent');
149
+ expect(result).toEqual([]);
240
150
  });
241
151
  });
242
152
 
243
- describe('Concurrent Processing', () => {
244
- it('should handle large-scale concurrent model processing', async () => {
245
- const manyModels = Array.from({ length: 10 }, (_, i) =>
246
- createChatModel(`model-${i}`, 'test-provider', {
247
- displayName: `Model ${i}`,
248
- abilities: { functionCall: i % 2 === 0 } satisfies ModelAbilities,
249
- contextWindowTokens: 4096 + i * 1000,
250
- }),
251
- );
153
+ describe('getImageModelList', () => {
154
+ const imageModels = [
155
+ createImageModel({ id: 'dall-e-3', providerId: 'openai', displayName: 'DALL-E 3' }),
156
+ createImageModel({ id: 'midjourney', providerId: 'midjourney', displayName: 'Midjourney' }),
157
+ ];
252
158
 
253
- const result = await getModelListByType(manyModels, 'test-provider', 'chat');
159
+ it('collects normalized image models for a provider', async () => {
160
+ const result = await getImageModelList(imageModels, 'openai');
254
161
 
255
- expect(result).toHaveLength(10);
256
- expect(result.map((m) => m.id)).toEqual(manyModels.map((m) => m.id));
257
-
258
- result.forEach((model, index) => {
259
- expect(model.abilities.functionCall).toBe(index % 2 === 0);
260
- expect(model.contextWindowTokens).toBe(4096 + index * 1000);
261
- });
162
+ expect(result).toHaveLength(1);
163
+ expect(result[0].id).toBe('dall-e-3');
164
+ expect(result[0].displayName).toBe('DALL-E 3');
262
165
  });
263
166
 
264
- it('should maintain model order during concurrent processing', async () => {
265
- const orderedModels = [
266
- createChatModel('first-model', 'test', { displayName: 'First Model' }),
267
- createChatModel('second-model', 'test', { displayName: 'Second Model' }),
268
- createChatModel('third-model', 'test', { displayName: 'Third Model' }),
269
- ];
270
-
271
- const result = await getModelListByType(orderedModels, 'test', 'chat');
272
-
273
- expect(result.map((m) => m.id)).toEqual(['first-model', 'second-model', 'third-model']);
167
+ it('returns empty array when provider has no image models', async () => {
168
+ const result = await getImageModelList(imageModels, 'unknown');
169
+ expect(result).toEqual([]);
274
170
  });
275
171
  });
276
172
  });
@@ -1,11 +1,13 @@
1
1
  import { isDeprecatedEdition, isDesktop, isUsePgliteDB } from '@lobechat/const';
2
- import { getModelPropertyWithFallback } from '@lobechat/model-runtime';
2
+ import { getModelPropertyWithFallback, resolveImageSinglePrice } from '@lobechat/model-runtime';
3
3
  import { uniqBy } from 'lodash-es';
4
4
  import {
5
5
  AIImageModelCard,
6
6
  EnabledAiModel,
7
7
  LobeDefaultAiModelListItem,
8
8
  ModelAbilities,
9
+ ModelParamsSchema,
10
+ Pricing,
9
11
  } from 'model-bank';
10
12
  import { SWRResponse, mutate } from 'swr';
11
13
  import { StateCreator } from 'zustand/vanilla';
@@ -28,52 +30,130 @@ import {
28
30
  UpdateAiProviderParams,
29
31
  } from '@/types/aiProvider';
30
32
 
31
- /**
32
- * Get models by provider ID and type, with proper formatting and deduplication
33
- */
34
- export const getModelListByType = async (
35
- enabledAiModels: EnabledAiModel[],
36
- providerId: string,
37
- type: string,
33
+ export type ProviderModelListItem = {
34
+ abilities: ModelAbilities;
35
+ approximatePricePerImage?: number;
36
+ contextWindowTokens?: number;
37
+ description?: string;
38
+ displayName: string;
39
+ id: string;
40
+ parameters?: ModelParamsSchema;
41
+ pricePerImage?: number;
42
+ pricing?: Pricing;
43
+ };
44
+
45
+ type ModelNormalizer = (model: EnabledAiModel) => Promise<ProviderModelListItem>;
46
+
47
+ const dedupeById = (models: ProviderModelListItem[]) => uniqBy(models, 'id');
48
+
49
+ const createProviderModelCollector = (
50
+ type: EnabledAiModel['type'],
51
+ normalizer: ModelNormalizer,
38
52
  ) => {
39
- const filteredModels = enabledAiModels.filter(
40
- (model) => model.providerId === providerId && model.type === type,
41
- );
53
+ return async (enabledAiModels: EnabledAiModel[], providerId: string) => {
54
+ const filteredModels = enabledAiModels.filter(
55
+ (model) => model.providerId === providerId && model.type === type,
56
+ );
42
57
 
43
- const models = await Promise.all(
44
- filteredModels.map(async (model) => ({
45
- abilities: (model.abilities || {}) as ModelAbilities,
46
- contextWindowTokens: model.contextWindowTokens,
47
- displayName: model.displayName ?? '',
48
- id: model.id,
49
- ...(model.type === 'image' && {
50
- parameters:
51
- (model as AIImageModelCard).parameters ||
52
- (await getModelPropertyWithFallback(model.id, 'parameters')),
53
- }),
54
- })),
58
+ if (!filteredModels.length) return [];
59
+
60
+ const normalized = await Promise.all(filteredModels.map((model) => normalizer(model)));
61
+ return dedupeById(normalized);
62
+ };
63
+ };
64
+
65
+ export const normalizeChatModel = (model: EnabledAiModel): ProviderModelListItem => ({
66
+ abilities: (model.abilities || {}) as ModelAbilities,
67
+ contextWindowTokens: model.contextWindowTokens,
68
+ displayName: model.displayName ?? '',
69
+ id: model.id,
70
+ });
71
+
72
+ export const normalizeImageModel = async (
73
+ model: EnabledAiModel,
74
+ ): Promise<ProviderModelListItem> => {
75
+ const fallbackParametersPromise = model.parameters
76
+ ? Promise.resolve<ModelParamsSchema | undefined>(model.parameters)
77
+ : getModelPropertyWithFallback<ModelParamsSchema | undefined>(
78
+ model.id,
79
+ 'parameters',
80
+ model.providerId,
81
+ );
82
+
83
+ const modelWithPricing = model as AIImageModelCard;
84
+ const fallbackPricingPromise = modelWithPricing.pricing
85
+ ? Promise.resolve<Pricing | undefined>(modelWithPricing.pricing)
86
+ : getModelPropertyWithFallback<Pricing | undefined>(model.id, 'pricing', model.providerId);
87
+
88
+ const fallbackDescriptionPromise = getModelPropertyWithFallback<string | undefined>(
89
+ model.id,
90
+ 'description',
91
+ model.providerId,
55
92
  );
56
93
 
57
- return uniqBy(models, 'id');
94
+ const [fallbackParameters, fallbackPricing, fallbackDescription] = await Promise.all([
95
+ fallbackParametersPromise,
96
+ fallbackPricingPromise,
97
+ fallbackDescriptionPromise,
98
+ ]);
99
+
100
+ const parameters = model.parameters ?? fallbackParameters;
101
+ const pricing = fallbackPricing;
102
+ const description = fallbackDescription;
103
+ const { price, approximatePrice } = resolveImageSinglePrice(pricing);
104
+
105
+ return {
106
+ abilities: (model.abilities || {}) as ModelAbilities,
107
+ contextWindowTokens: model.contextWindowTokens,
108
+ displayName: model.displayName ?? '',
109
+ id: model.id,
110
+ ...(parameters && { parameters }),
111
+ ...(description && { description }),
112
+ ...(pricing && { pricing }),
113
+ ...(typeof approximatePrice === 'number' && { approximatePricePerImage: approximatePrice }),
114
+ ...(typeof price === 'number' && { pricePerImage: price }),
115
+ };
58
116
  };
59
117
 
60
- /**
61
- * Build provider model lists with proper async handling
62
- */
118
+ export const getChatModelList = createProviderModelCollector('chat', async (model) =>
119
+ normalizeChatModel(model),
120
+ );
121
+
122
+ export const getImageModelList = createProviderModelCollector('image', normalizeImageModel);
123
+
63
124
  const buildProviderModelLists = async (
64
125
  providers: EnabledProvider[],
65
126
  enabledAiModels: EnabledAiModel[],
66
- type: 'chat' | 'image',
127
+ collector: (
128
+ enabledAiModels: EnabledAiModel[],
129
+ providerId: string,
130
+ ) => Promise<ProviderModelListItem[]>,
67
131
  ) => {
68
132
  return Promise.all(
69
133
  providers.map(async (provider) => ({
70
134
  ...provider,
71
- children: await getModelListByType(enabledAiModels, provider.id, type),
135
+ children: await collector(enabledAiModels, provider.id),
72
136
  name: provider.name || provider.id,
73
137
  })),
74
138
  );
75
139
  };
76
140
 
141
+ /**
142
+ * Build image provider model lists with proper async handling
143
+ */
144
+ const buildImageProviderModelLists = async (
145
+ providers: EnabledProvider[],
146
+ enabledAiModels: EnabledAiModel[],
147
+ ) => buildProviderModelLists(providers, enabledAiModels, getImageModelList);
148
+
149
+ /**
150
+ * Build chat provider model lists with proper async handling
151
+ */
152
+ const buildChatProviderModelLists = async (
153
+ providers: EnabledProvider[],
154
+ enabledAiModels: EnabledAiModel[],
155
+ ) => buildProviderModelLists(providers, enabledAiModels, getChatModelList);
156
+
77
157
  enum AiProviderSwrKey {
78
158
  fetchAiProviderItem = 'FETCH_AI_PROVIDER_ITEM',
79
159
  fetchAiProviderList = 'FETCH_AI_PROVIDER',
@@ -252,8 +332,8 @@ export const createAiProviderSlice: StateCreator<
252
332
 
253
333
  // Build model lists with proper async handling
254
334
  const [enabledChatModelList, enabledImageModelList] = await Promise.all([
255
- buildProviderModelLists(data.enabledChatAiProviders, data.enabledAiModels, 'chat'),
256
- buildProviderModelLists(data.enabledImageAiProviders, data.enabledAiModels, 'image'),
335
+ buildChatProviderModelLists(data.enabledChatAiProviders, data.enabledAiModels),
336
+ buildImageProviderModelLists(data.enabledImageAiProviders, data.enabledAiModels),
257
337
  ]);
258
338
 
259
339
  return {
@@ -285,8 +365,8 @@ export const createAiProviderSlice: StateCreator<
285
365
  // Build model lists for non-login state as well
286
366
  const enabledAiModels = builtinAiModelList.filter((m) => m.enabled);
287
367
  const [enabledChatModelList, enabledImageModelList] = await Promise.all([
288
- buildProviderModelLists(enabledChatAiProviders, enabledAiModels, 'chat'),
289
- buildProviderModelLists(enabledImageAiProviders, enabledAiModels, 'image'),
368
+ buildChatProviderModelLists(enabledChatAiProviders, enabledAiModels),
369
+ buildImageProviderModelLists(enabledImageAiProviders, enabledAiModels),
290
370
  ]);
291
371
 
292
372
  return {