@lobehub/chat 1.131.4 → 1.132.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/changelog/v1.json +18 -0
- package/package.json +1 -1
- package/packages/context-engine/src/processors/MessageContent.ts +45 -10
- package/packages/context-engine/src/processors/__tests__/MessageContent.test.ts +179 -1
- package/packages/database/src/models/message.ts +9 -1
- package/packages/model-bank/src/aiModels/google.ts +7 -0
- package/packages/model-runtime/src/providers/google/index.ts +31 -8
- package/packages/model-runtime/src/types/chat.ts +6 -0
- package/packages/prompts/src/prompts/files/index.test.ts +148 -3
- package/packages/prompts/src/prompts/files/index.ts +17 -5
- package/packages/prompts/src/prompts/files/video.ts +17 -0
- package/packages/types/src/agent/index.ts +1 -1
- package/packages/types/src/message/chat.ts +2 -4
- package/packages/types/src/message/index.ts +1 -0
- package/packages/types/src/message/video.ts +5 -0
- package/packages/utils/src/client/index.ts +1 -0
- package/packages/utils/src/client/videoValidation.test.ts +53 -0
- package/packages/utils/src/client/videoValidation.ts +21 -0
- package/packages/utils/src/parseModels.ts +4 -0
- package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/useSend.ts +9 -4
- package/src/components/ModelSelect/index.tsx +14 -2
- package/src/features/ChatInput/ActionBar/Upload/ClientMode.tsx +7 -0
- package/src/features/ChatInput/ActionBar/Upload/ServerMode.tsx +29 -3
- package/src/features/ChatInput/components/UploadDetail/UploadStatus.tsx +1 -1
- package/src/features/Conversation/Messages/Assistant/index.tsx +4 -1
- package/src/features/Conversation/Messages/User/VideoFileListViewer.tsx +31 -0
- package/src/features/Conversation/Messages/User/index.tsx +3 -1
- package/src/hooks/useModelSupportVideo.ts +10 -0
- package/src/locales/default/chat.ts +4 -0
- package/src/locales/default/components.ts +1 -0
- package/src/server/routers/lambda/aiChat.ts +1 -0
- package/src/services/chat/contextEngineering.test.ts +0 -1
- package/src/services/chat/contextEngineering.ts +3 -1
- package/src/services/chat/helper.ts +4 -0
- package/src/services/upload.ts +1 -1
- package/src/store/aiInfra/slices/aiModel/selectors.ts +7 -0
- package/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts +22 -0
- package/src/store/chat/slices/message/action.ts +15 -14
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChatFileItem, ChatImageItem } from '@lobechat/types';
|
|
1
|
+
import { ChatFileItem, ChatImageItem, ChatVideoItem } from '@lobechat/types';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { filesPrompts } from './index';
|
|
@@ -19,6 +19,12 @@ describe('filesPrompts', () => {
|
|
|
19
19
|
url: 'https://example.com/test.pdf',
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
const mockVideo: ChatVideoItem = {
|
|
23
|
+
id: 'video-1',
|
|
24
|
+
alt: 'test video',
|
|
25
|
+
url: 'https://example.com/video.mp4',
|
|
26
|
+
};
|
|
27
|
+
|
|
22
28
|
it('should generate prompt with only images', () => {
|
|
23
29
|
const result = filesPrompts({
|
|
24
30
|
imageList: [mockImage],
|
|
@@ -37,7 +43,6 @@ describe('filesPrompts', () => {
|
|
|
37
43
|
<images_docstring>here are user upload images you can refer to</images_docstring>
|
|
38
44
|
<image name="test image" url="https://example.com/image.jpg"></image>
|
|
39
45
|
</images>
|
|
40
|
-
|
|
41
46
|
</files_info>
|
|
42
47
|
<!-- END SYSTEM CONTEXT -->`,
|
|
43
48
|
);
|
|
@@ -57,7 +62,6 @@ describe('filesPrompts', () => {
|
|
|
57
62
|
2. the context is only required when user's queries rely on it.
|
|
58
63
|
</context.instruction>
|
|
59
64
|
<files_info>
|
|
60
|
-
|
|
61
65
|
<files>
|
|
62
66
|
<files_docstring>here are user upload files you can refer to</files_docstring>
|
|
63
67
|
<file id="file-1" name="test.pdf" type="application/pdf" size="1024" url="https://example.com/test.pdf"></file>
|
|
@@ -184,4 +188,145 @@ describe('filesPrompts', () => {
|
|
|
184
188
|
<!-- END SYSTEM CONTEXT -->"
|
|
185
189
|
`);
|
|
186
190
|
});
|
|
191
|
+
|
|
192
|
+
describe('Video functionality', () => {
|
|
193
|
+
it('should generate prompt with only videos', () => {
|
|
194
|
+
const result = filesPrompts({
|
|
195
|
+
videoList: [mockVideo],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(result).toEqual(
|
|
199
|
+
`<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
|
|
200
|
+
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
|
|
201
|
+
|
|
202
|
+
1. Always prioritize handling user-visible content.
|
|
203
|
+
2. the context is only required when user's queries rely on it.
|
|
204
|
+
</context.instruction>
|
|
205
|
+
<files_info>
|
|
206
|
+
<videos>
|
|
207
|
+
<videos_docstring>here are user upload videos you can refer to</videos_docstring>
|
|
208
|
+
<video name="test video" url="https://example.com/video.mp4"></video>
|
|
209
|
+
</videos>
|
|
210
|
+
</files_info>
|
|
211
|
+
<!-- END SYSTEM CONTEXT -->`,
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('should generate prompt with videos and images', () => {
|
|
216
|
+
const result = filesPrompts({
|
|
217
|
+
imageList: [mockImage],
|
|
218
|
+
videoList: [mockVideo],
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(result).toEqual(
|
|
222
|
+
`<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
|
|
223
|
+
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
|
|
224
|
+
|
|
225
|
+
1. Always prioritize handling user-visible content.
|
|
226
|
+
2. the context is only required when user's queries rely on it.
|
|
227
|
+
</context.instruction>
|
|
228
|
+
<files_info>
|
|
229
|
+
<images>
|
|
230
|
+
<images_docstring>here are user upload images you can refer to</images_docstring>
|
|
231
|
+
<image name="test image" url="https://example.com/image.jpg"></image>
|
|
232
|
+
</images>
|
|
233
|
+
<videos>
|
|
234
|
+
<videos_docstring>here are user upload videos you can refer to</videos_docstring>
|
|
235
|
+
<video name="test video" url="https://example.com/video.mp4"></video>
|
|
236
|
+
</videos>
|
|
237
|
+
</files_info>
|
|
238
|
+
<!-- END SYSTEM CONTEXT -->`,
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should generate prompt with all media types', () => {
|
|
243
|
+
const result = filesPrompts({
|
|
244
|
+
imageList: [mockImage],
|
|
245
|
+
fileList: [mockFile],
|
|
246
|
+
videoList: [mockVideo],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(result).toEqual(
|
|
250
|
+
`<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
|
|
251
|
+
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
|
|
252
|
+
|
|
253
|
+
1. Always prioritize handling user-visible content.
|
|
254
|
+
2. the context is only required when user's queries rely on it.
|
|
255
|
+
</context.instruction>
|
|
256
|
+
<files_info>
|
|
257
|
+
<images>
|
|
258
|
+
<images_docstring>here are user upload images you can refer to</images_docstring>
|
|
259
|
+
<image name="test image" url="https://example.com/image.jpg"></image>
|
|
260
|
+
</images>
|
|
261
|
+
<files>
|
|
262
|
+
<files_docstring>here are user upload files you can refer to</files_docstring>
|
|
263
|
+
<file id="file-1" name="test.pdf" type="application/pdf" size="1024" url="https://example.com/test.pdf"></file>
|
|
264
|
+
</files>
|
|
265
|
+
<videos>
|
|
266
|
+
<videos_docstring>here are user upload videos you can refer to</videos_docstring>
|
|
267
|
+
<video name="test video" url="https://example.com/video.mp4"></video>
|
|
268
|
+
</videos>
|
|
269
|
+
</files_info>
|
|
270
|
+
<!-- END SYSTEM CONTEXT -->`,
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should handle multiple videos', () => {
|
|
275
|
+
const videos: ChatVideoItem[] = [
|
|
276
|
+
mockVideo,
|
|
277
|
+
{
|
|
278
|
+
id: 'video-2',
|
|
279
|
+
alt: 'second video',
|
|
280
|
+
url: 'https://example.com/video2.mp4',
|
|
281
|
+
},
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
const result = filesPrompts({
|
|
285
|
+
videoList: videos,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
expect(result).toContain('test video');
|
|
289
|
+
expect(result).toContain('second video');
|
|
290
|
+
expect(result).toMatch(/<video.*?>.*<video.*?>/s); // Check for multiple video tags
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should handle videos without url when addUrl is false', () => {
|
|
294
|
+
const result = filesPrompts({
|
|
295
|
+
videoList: [mockVideo],
|
|
296
|
+
addUrl: false,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(result).toMatchInlineSnapshot(`
|
|
300
|
+
"<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
|
|
301
|
+
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
|
|
302
|
+
|
|
303
|
+
1. Always prioritize handling user-visible content.
|
|
304
|
+
2. the context is only required when user's queries rely on it.
|
|
305
|
+
</context.instruction>
|
|
306
|
+
<files_info>
|
|
307
|
+
<videos>
|
|
308
|
+
<videos_docstring>here are user upload videos you can refer to</videos_docstring>
|
|
309
|
+
<video name="test video"></video>
|
|
310
|
+
</videos>
|
|
311
|
+
</files_info>
|
|
312
|
+
<!-- END SYSTEM CONTEXT -->"
|
|
313
|
+
`);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should return empty string when all lists are empty', () => {
|
|
317
|
+
const result = filesPrompts({
|
|
318
|
+
imageList: [],
|
|
319
|
+
fileList: [],
|
|
320
|
+
videoList: [],
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(result).toEqual('');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should return empty string when no lists are provided', () => {
|
|
327
|
+
const result = filesPrompts({});
|
|
328
|
+
|
|
329
|
+
expect(result).toEqual('');
|
|
330
|
+
});
|
|
331
|
+
});
|
|
187
332
|
});
|
|
@@ -1,18 +1,31 @@
|
|
|
1
|
-
import { ChatFileItem, ChatImageItem } from '@lobechat/types';
|
|
1
|
+
import { ChatFileItem, ChatImageItem, ChatVideoItem } from '@lobechat/types';
|
|
2
2
|
|
|
3
3
|
import { filePrompts } from './file';
|
|
4
4
|
import { imagesPrompts } from './image';
|
|
5
|
+
import { videosPrompts } from './video';
|
|
5
6
|
|
|
6
7
|
export const filesPrompts = ({
|
|
7
8
|
imageList,
|
|
8
9
|
fileList,
|
|
10
|
+
videoList,
|
|
9
11
|
addUrl = true,
|
|
10
12
|
}: {
|
|
11
13
|
addUrl?: boolean;
|
|
12
14
|
fileList?: ChatFileItem[];
|
|
13
|
-
imageList
|
|
15
|
+
imageList?: ChatImageItem[];
|
|
16
|
+
videoList?: ChatVideoItem[];
|
|
14
17
|
}) => {
|
|
15
|
-
|
|
18
|
+
const hasImages = (imageList || []).length > 0;
|
|
19
|
+
const hasFiles = (fileList || []).length > 0;
|
|
20
|
+
const hasVideos = (videoList || []).length > 0;
|
|
21
|
+
|
|
22
|
+
if (!hasImages && !hasFiles && !hasVideos) return '';
|
|
23
|
+
|
|
24
|
+
const contentParts = [
|
|
25
|
+
hasImages ? imagesPrompts(imageList!, addUrl) : '',
|
|
26
|
+
hasFiles ? filePrompts(fileList!, addUrl) : '',
|
|
27
|
+
hasVideos ? videosPrompts(videoList!, addUrl) : '',
|
|
28
|
+
].filter(Boolean);
|
|
16
29
|
|
|
17
30
|
const prompt = `<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
|
|
18
31
|
<context.instruction>following part contains context information injected by the system. Please follow these instructions:
|
|
@@ -21,8 +34,7 @@ export const filesPrompts = ({
|
|
|
21
34
|
2. the context is only required when user's queries rely on it.
|
|
22
35
|
</context.instruction>
|
|
23
36
|
<files_info>
|
|
24
|
-
${
|
|
25
|
-
${fileList ? filePrompts(fileList, addUrl) : ''}
|
|
37
|
+
${contentParts.join('\n')}
|
|
26
38
|
</files_info>
|
|
27
39
|
<!-- END SYSTEM CONTEXT -->`;
|
|
28
40
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ChatVideoItem } from '@lobechat/types';
|
|
2
|
+
|
|
3
|
+
const videoPrompt = (item: ChatVideoItem, attachUrl: boolean) =>
|
|
4
|
+
attachUrl
|
|
5
|
+
? `<video name="${item.alt}" url="${item.url}"></video>`
|
|
6
|
+
: `<video name="${item.alt}"></video>`;
|
|
7
|
+
|
|
8
|
+
export const videosPrompts = (videoList: ChatVideoItem[], addUrl: boolean = true) => {
|
|
9
|
+
if (videoList.length === 0) return '';
|
|
10
|
+
|
|
11
|
+
const prompt = `<videos>
|
|
12
|
+
<videos_docstring>here are user upload videos you can refer to</videos_docstring>
|
|
13
|
+
${videoList.map((item) => videoPrompt(item, addUrl)).join('\n')}
|
|
14
|
+
</videos>`;
|
|
15
|
+
|
|
16
|
+
return prompt.trim();
|
|
17
|
+
};
|
|
@@ -5,6 +5,7 @@ import type { ChatMessageError, MessageMetadata, MessageRoleType, ModelReasoning
|
|
|
5
5
|
import { ChatImageItem } from './image';
|
|
6
6
|
import { ChatPluginPayload, ChatToolPayload } from './tools';
|
|
7
7
|
import { Translate } from './translate';
|
|
8
|
+
import { ChatVideoItem } from './video';
|
|
8
9
|
|
|
9
10
|
export interface ChatTranslate extends Translate {
|
|
10
11
|
content?: string;
|
|
@@ -63,13 +64,11 @@ export interface ChatMessage {
|
|
|
63
64
|
id: string;
|
|
64
65
|
imageList?: ChatImageItem[];
|
|
65
66
|
meta: MetaData;
|
|
66
|
-
|
|
67
67
|
metadata?: MessageMetadata | null;
|
|
68
68
|
/**
|
|
69
69
|
* observation id
|
|
70
70
|
*/
|
|
71
71
|
observationId?: string;
|
|
72
|
-
|
|
73
72
|
/**
|
|
74
73
|
* parent message id
|
|
75
74
|
*/
|
|
@@ -83,14 +82,12 @@ export interface ChatMessage {
|
|
|
83
82
|
quotaId?: string;
|
|
84
83
|
ragQuery?: string | null;
|
|
85
84
|
ragQueryId?: string | null;
|
|
86
|
-
|
|
87
85
|
ragRawQuery?: string | null;
|
|
88
86
|
reasoning?: ModelReasoning | null;
|
|
89
87
|
/**
|
|
90
88
|
* message role type
|
|
91
89
|
*/
|
|
92
90
|
role: MessageRoleType;
|
|
93
|
-
|
|
94
91
|
search?: GroundingSearch | null;
|
|
95
92
|
sessionId?: string;
|
|
96
93
|
threadId?: string | null;
|
|
@@ -105,6 +102,7 @@ export interface ChatMessage {
|
|
|
105
102
|
*/
|
|
106
103
|
traceId?: string;
|
|
107
104
|
updatedAt: number;
|
|
105
|
+
videoList?: ChatVideoItem[];
|
|
108
106
|
}
|
|
109
107
|
|
|
110
108
|
export interface CreateMessageParams
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { validateVideoFileSize } from './videoValidation';
|
|
4
|
+
|
|
5
|
+
describe('validateVideoFileSize', () => {
|
|
6
|
+
it('should return valid for non-video files', () => {
|
|
7
|
+
const mockFile = new File(['test'], 'test.txt', { type: 'text/plain' });
|
|
8
|
+
const result = validateVideoFileSize(mockFile);
|
|
9
|
+
|
|
10
|
+
expect(result.isValid).toBe(true);
|
|
11
|
+
expect(result.actualSize).toBeUndefined();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should return valid for video files under 20MB', () => {
|
|
15
|
+
const mockVideoFile = new File(['x'.repeat(10 * 1024 * 1024)], 'video.mp4', {
|
|
16
|
+
type: 'video/mp4',
|
|
17
|
+
});
|
|
18
|
+
const result = validateVideoFileSize(mockVideoFile);
|
|
19
|
+
|
|
20
|
+
expect(result.isValid).toBe(true);
|
|
21
|
+
expect(result.actualSize).toBe('10.0 MB');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should return invalid for video files over 20MB', () => {
|
|
25
|
+
const mockLargeVideoFile = new File(['x'.repeat(25 * 1024 * 1024)], 'large-video.mp4', {
|
|
26
|
+
type: 'video/mp4',
|
|
27
|
+
});
|
|
28
|
+
const result = validateVideoFileSize(mockLargeVideoFile);
|
|
29
|
+
|
|
30
|
+
expect(result.isValid).toBe(false);
|
|
31
|
+
expect(result.actualSize).toBe('25.0 MB');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should return invalid for video files exactly at 20MB limit plus 1 byte', () => {
|
|
35
|
+
const mockBoundaryFile = new File(['x'.repeat(20 * 1024 * 1024 + 1)], 'boundary.mp4', {
|
|
36
|
+
type: 'video/mp4',
|
|
37
|
+
});
|
|
38
|
+
const result = validateVideoFileSize(mockBoundaryFile);
|
|
39
|
+
|
|
40
|
+
expect(result.isValid).toBe(false);
|
|
41
|
+
expect(result.actualSize).toBe('20.0 MB');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should return valid for video files exactly at 20MB limit', () => {
|
|
45
|
+
const mockBoundaryFile = new File(['x'.repeat(20 * 1024 * 1024)], 'boundary.mp4', {
|
|
46
|
+
type: 'video/mp4',
|
|
47
|
+
});
|
|
48
|
+
const result = validateVideoFileSize(mockBoundaryFile);
|
|
49
|
+
|
|
50
|
+
expect(result.isValid).toBe(true);
|
|
51
|
+
expect(result.actualSize).toBe('20.0 MB');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { formatSize } from '../format';
|
|
2
|
+
|
|
3
|
+
const VIDEO_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB in bytes
|
|
4
|
+
|
|
5
|
+
export interface VideoValidationResult {
|
|
6
|
+
actualSize?: string;
|
|
7
|
+
isValid: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const validateVideoFileSize = (file: File): VideoValidationResult => {
|
|
11
|
+
if (!file.type.startsWith('video/')) {
|
|
12
|
+
return { isValid: true };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const isValid = file.size <= VIDEO_SIZE_LIMIT;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
actualSize: formatSize(file.size),
|
|
19
|
+
isValid,
|
|
20
|
+
};
|
|
21
|
+
};
|
package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/useSend.ts
CHANGED
|
@@ -38,13 +38,14 @@ export const useSend = () => {
|
|
|
38
38
|
const { analytics } = useAnalytics();
|
|
39
39
|
const checkGeminiChineseWarning = useGeminiChineseWarning();
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
// 使用订阅以保持最新文件列表
|
|
42
|
+
const reactiveFileList = useFileStore(fileChatSelectors.chatUploadFileList);
|
|
42
43
|
const [isUploadingFiles, clearChatUploadFileList] = useFileStore((s) => [
|
|
43
44
|
fileChatSelectors.isUploadingFiles(s),
|
|
44
45
|
s.clearChatUploadFileList,
|
|
45
46
|
]);
|
|
46
47
|
|
|
47
|
-
const isInputEmpty = isContentEmpty &&
|
|
48
|
+
const isInputEmpty = isContentEmpty && reactiveFileList.length === 0;
|
|
48
49
|
|
|
49
50
|
const canNotSend =
|
|
50
51
|
isInputEmpty || isUploadingFiles || isSendButtonDisabledByMessage || isSendingMessage;
|
|
@@ -63,6 +64,8 @@ export const useSend = () => {
|
|
|
63
64
|
if (chatSelectors.isAIGenerating(store)) return;
|
|
64
65
|
|
|
65
66
|
const inputMessage = store.inputMessage;
|
|
67
|
+
// 发送时再取一次最新的文件列表,防止闭包拿到旧值
|
|
68
|
+
const fileList = fileChatSelectors.chatUploadFileList(useFileStore.getState());
|
|
66
69
|
|
|
67
70
|
// if there is no message and no image, then we should not send the message
|
|
68
71
|
if (!inputMessage && fileList.length === 0) return;
|
|
@@ -92,9 +95,11 @@ export const useSend = () => {
|
|
|
92
95
|
// 获取分析数据
|
|
93
96
|
const userStore = getUserStoreState();
|
|
94
97
|
|
|
95
|
-
//
|
|
98
|
+
// 直接使用现有数据结构判断消息类型(支持 video)
|
|
96
99
|
const hasImages = fileList.some((file) => file.file?.type?.startsWith('image'));
|
|
97
|
-
const
|
|
100
|
+
const hasVideos = fileList.some((file) => file.file?.type?.startsWith('video'));
|
|
101
|
+
const messageType =
|
|
102
|
+
fileList.length === 0 ? 'text' : hasVideos ? 'video' : hasImages ? 'image' : 'file';
|
|
98
103
|
|
|
99
104
|
analytics?.track({
|
|
100
105
|
name: 'send_message',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ChatModelCard } from '@lobechat/types';
|
|
1
2
|
import { IconAvatarProps, ModelIcon, ProviderIcon } from '@lobehub/icons';
|
|
2
3
|
import { Avatar, Icon, Tag, Text, Tooltip } from '@lobehub/ui';
|
|
3
4
|
import { createStyles, useResponsive } from 'antd-style';
|
|
@@ -9,15 +10,15 @@ import {
|
|
|
9
10
|
LucideImage,
|
|
10
11
|
LucidePaperclip,
|
|
11
12
|
ToyBrick,
|
|
13
|
+
Video,
|
|
12
14
|
} from 'lucide-react';
|
|
15
|
+
import { ModelAbilities } from 'model-bank';
|
|
13
16
|
import numeral from 'numeral';
|
|
14
17
|
import { FC, memo } from 'react';
|
|
15
18
|
import { useTranslation } from 'react-i18next';
|
|
16
19
|
import { Flexbox } from 'react-layout-kit';
|
|
17
20
|
|
|
18
|
-
import { ModelAbilities } from '../../../packages/model-bank/src/types/aiModel';
|
|
19
21
|
import { AiProviderSourceType } from '@/types/aiProvider';
|
|
20
|
-
import { ChatModelCard } from '@/types/llm';
|
|
21
22
|
import { formatTokenNumber } from '@/utils/format';
|
|
22
23
|
|
|
23
24
|
export const TAG_CLASSNAME = 'lobe-model-info-tags';
|
|
@@ -99,6 +100,17 @@ export const ModelInfoTags = memo<ModelInfoTagsProps>(
|
|
|
99
100
|
</Tag>
|
|
100
101
|
</Tooltip>
|
|
101
102
|
)}
|
|
103
|
+
{model.video && (
|
|
104
|
+
<Tooltip
|
|
105
|
+
placement={placement}
|
|
106
|
+
styles={{ root: { pointerEvents: 'none' } }}
|
|
107
|
+
title={t('ModelSelect.featureTag.video')}
|
|
108
|
+
>
|
|
109
|
+
<Tag className={styles.tag} color={'magenta'} size={'small'}>
|
|
110
|
+
<Icon icon={Video} />
|
|
111
|
+
</Tag>
|
|
112
|
+
</Tooltip>
|
|
113
|
+
)}
|
|
102
114
|
{model.functionCall && (
|
|
103
115
|
<Tooltip
|
|
104
116
|
placement={placement}
|
|
@@ -4,6 +4,7 @@ import { FileUp, LucideImage } from 'lucide-react';
|
|
|
4
4
|
import { memo } from 'react';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
|
|
7
|
+
import { message } from '@/components/AntdStaticMethods';
|
|
7
8
|
import { useModelSupportFiles } from '@/hooks/useModelSupportFiles';
|
|
8
9
|
import { useModelSupportVision } from '@/hooks/useModelSupportVision';
|
|
9
10
|
import { useAgentStore } from '@/store/agent';
|
|
@@ -26,6 +27,12 @@ const FileUpload = memo(() => {
|
|
|
26
27
|
<Upload
|
|
27
28
|
accept={enabledFiles ? undefined : 'image/*'}
|
|
28
29
|
beforeUpload={async (file) => {
|
|
30
|
+
// Check if trying to upload non-image files in client mode
|
|
31
|
+
if (!enabledFiles && !file.type.startsWith('image')) {
|
|
32
|
+
message.warning(t('upload.clientMode.fileNotSupported'));
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
29
36
|
await upload([file]);
|
|
30
37
|
|
|
31
38
|
return false;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validateVideoFileSize } from '@lobechat/utils/client';
|
|
1
2
|
import { MenuProps, Tooltip } from '@lobehub/ui';
|
|
2
3
|
import { Upload } from 'antd';
|
|
3
4
|
import { css, cx } from 'antd-style';
|
|
@@ -5,9 +6,10 @@ import { FileUp, FolderUp, ImageUp, Paperclip } from 'lucide-react';
|
|
|
5
6
|
import { memo } from 'react';
|
|
6
7
|
import { useTranslation } from 'react-i18next';
|
|
7
8
|
|
|
9
|
+
import { message } from '@/components/AntdStaticMethods';
|
|
8
10
|
import { useModelSupportVision } from '@/hooks/useModelSupportVision';
|
|
9
11
|
import { useAgentStore } from '@/store/agent';
|
|
10
|
-
import { agentSelectors } from '@/store/agent/
|
|
12
|
+
import { agentSelectors } from '@/store/agent/selectors';
|
|
11
13
|
import { useFileStore } from '@/store/file';
|
|
12
14
|
|
|
13
15
|
import Action from '../components/Action';
|
|
@@ -61,7 +63,19 @@ const FileUpload = memo(() => {
|
|
|
61
63
|
label: (
|
|
62
64
|
<Upload
|
|
63
65
|
beforeUpload={async (file) => {
|
|
64
|
-
if (!canUploadImage && file.type.startsWith('image')
|
|
66
|
+
if (!canUploadImage && (file.type.startsWith('image') || file.type.startsWith('video')))
|
|
67
|
+
return false;
|
|
68
|
+
|
|
69
|
+
// Validate video file size
|
|
70
|
+
const validation = validateVideoFileSize(file);
|
|
71
|
+
if (!validation.isValid) {
|
|
72
|
+
message.error(
|
|
73
|
+
t('upload.validation.videoSizeExceeded', {
|
|
74
|
+
actualSize: validation.actualSize,
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
65
79
|
|
|
66
80
|
await upload([file]);
|
|
67
81
|
|
|
@@ -80,7 +94,19 @@ const FileUpload = memo(() => {
|
|
|
80
94
|
label: (
|
|
81
95
|
<Upload
|
|
82
96
|
beforeUpload={async (file) => {
|
|
83
|
-
if (!canUploadImage && file.type.startsWith('image')
|
|
97
|
+
if (!canUploadImage && (file.type.startsWith('image') || file.type.startsWith('video')))
|
|
98
|
+
return false;
|
|
99
|
+
|
|
100
|
+
// Validate video file size
|
|
101
|
+
const validation = validateVideoFileSize(file);
|
|
102
|
+
if (!validation.isValid) {
|
|
103
|
+
message.error(
|
|
104
|
+
t('upload.validation.videoSizeExceeded', {
|
|
105
|
+
actualSize: validation.actualSize,
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
84
110
|
|
|
85
111
|
await upload([file]);
|
|
86
112
|
|
|
@@ -38,7 +38,7 @@ const UploadStatus = memo<UploadStateProps>(({ status, size, uploadState }) => {
|
|
|
38
38
|
<Flexbox align={'center'} gap={4} horizontal>
|
|
39
39
|
<Progress percent={uploadState?.progress} size={14} type="circle" />
|
|
40
40
|
<Text style={{ fontSize: 12 }} type={'secondary'}>
|
|
41
|
-
{formatSize(size * ((uploadState?.progress || 0) / 100), 0)}
|
|
41
|
+
{formatSize(size * ((uploadState?.progress || 0) / 100), 0)}
|
|
42
42
|
</Text>
|
|
43
43
|
</Flexbox>
|
|
44
44
|
);
|
|
@@ -3,6 +3,7 @@ import { Flexbox } from 'react-layout-kit';
|
|
|
3
3
|
|
|
4
4
|
import { LOADING_FLAT } from '@/const/message';
|
|
5
5
|
import ImageFileListViewer from '@/features/Conversation/Messages/User/ImageFileListViewer';
|
|
6
|
+
import VideoFileListViewer from '@/features/Conversation/Messages/User/VideoFileListViewer';
|
|
6
7
|
import { useChatStore } from '@/store/chat';
|
|
7
8
|
import { aiChatSelectors, chatSelectors } from '@/store/chat/selectors';
|
|
8
9
|
import { ChatMessage } from '@/types/message';
|
|
@@ -18,7 +19,7 @@ export const AssistantMessage = memo<
|
|
|
18
19
|
ChatMessage & {
|
|
19
20
|
editableContent: ReactNode;
|
|
20
21
|
}
|
|
21
|
-
>(({ id, tools, content, chunksList, search, imageList, ...props }) => {
|
|
22
|
+
>(({ id, tools, content, chunksList, search, imageList, videoList, ...props }) => {
|
|
22
23
|
const editing = useChatStore(chatSelectors.isMessageEditing(id));
|
|
23
24
|
const generating = useChatStore(chatSelectors.isMessageGenerating(id));
|
|
24
25
|
|
|
@@ -30,6 +31,7 @@ export const AssistantMessage = memo<
|
|
|
30
31
|
|
|
31
32
|
const showSearch = !!search && !!search.citations?.length;
|
|
32
33
|
const showImageItems = !!imageList && imageList.length > 0;
|
|
34
|
+
const showVideoItems = !!videoList && videoList.length > 0;
|
|
33
35
|
|
|
34
36
|
// remove \n to avoid empty content
|
|
35
37
|
// refs: https://github.com/lobehub/lobe-chat/pull/6153
|
|
@@ -67,6 +69,7 @@ export const AssistantMessage = memo<
|
|
|
67
69
|
)
|
|
68
70
|
)}
|
|
69
71
|
{showImageItems && <ImageFileListViewer items={imageList} />}
|
|
72
|
+
{showVideoItems && <VideoFileListViewer items={videoList} />}
|
|
70
73
|
{tools && (
|
|
71
74
|
<Flexbox gap={8}>
|
|
72
75
|
{tools.map((toolCall, index) => (
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { memo } from 'react';
|
|
2
|
+
import { Flexbox } from 'react-layout-kit';
|
|
3
|
+
|
|
4
|
+
import { ChatVideoItem } from '@/types/message';
|
|
5
|
+
|
|
6
|
+
interface VideoFileListViewerProps {
|
|
7
|
+
items: ChatVideoItem[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const VideoFileListViewer = memo<VideoFileListViewerProps>(({ items }) => {
|
|
11
|
+
return (
|
|
12
|
+
<Flexbox gap={8}>
|
|
13
|
+
{items.map((item) => (
|
|
14
|
+
<video
|
|
15
|
+
controls
|
|
16
|
+
key={item.id}
|
|
17
|
+
style={{
|
|
18
|
+
borderRadius: 8,
|
|
19
|
+
maxHeight: 400,
|
|
20
|
+
maxWidth: '100%',
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
<source src={item.url} />
|
|
24
|
+
{item.alt}
|
|
25
|
+
</video>
|
|
26
|
+
))}
|
|
27
|
+
</Flexbox>
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export default VideoFileListViewer;
|
|
@@ -7,18 +7,20 @@ import { ChatMessage } from '@/types/message';
|
|
|
7
7
|
|
|
8
8
|
import FileListViewer from './FileListViewer';
|
|
9
9
|
import ImageFileListViewer from './ImageFileListViewer';
|
|
10
|
+
import VideoFileListViewer from './VideoFileListViewer';
|
|
10
11
|
|
|
11
12
|
export const UserMessage = memo<
|
|
12
13
|
ChatMessage & {
|
|
13
14
|
editableContent: ReactNode;
|
|
14
15
|
}
|
|
15
|
-
>(({ id, editableContent, content, imageList, fileList }) => {
|
|
16
|
+
>(({ id, editableContent, content, imageList, videoList, fileList }) => {
|
|
16
17
|
if (content === LOADING_FLAT) return <BubblesLoading />;
|
|
17
18
|
|
|
18
19
|
return (
|
|
19
20
|
<Flexbox gap={8} id={id}>
|
|
20
21
|
{editableContent}
|
|
21
22
|
{imageList && imageList?.length > 0 && <ImageFileListViewer items={imageList} />}
|
|
23
|
+
{videoList && videoList?.length > 0 && <VideoFileListViewer items={videoList} />}
|
|
22
24
|
{fileList && fileList?.length > 0 && (
|
|
23
25
|
<div style={{ marginTop: 8 }}>
|
|
24
26
|
<FileListViewer items={fileList} />
|