@lobehub/lobehub 2.0.0-next.114 → 2.0.0-next.116

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 (31) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/changelog/v1.json +18 -0
  3. package/package.json +1 -1
  4. package/packages/const/src/models.ts +6 -0
  5. package/packages/context-engine/src/processors/MessageContent.ts +100 -6
  6. package/packages/context-engine/src/processors/__tests__/MessageContent.test.ts +239 -0
  7. package/packages/fetch-sse/src/fetchSSE.ts +30 -0
  8. package/packages/model-bank/src/aiModels/aihubmix.ts +35 -1
  9. package/packages/model-bank/src/aiModels/anthropic.ts +37 -2
  10. package/packages/model-bank/src/aiModels/bedrock.ts +26 -11
  11. package/packages/model-bank/src/aiModels/openrouter.ts +28 -1
  12. package/packages/model-bank/src/aiModels/zenmux.ts +30 -1
  13. package/packages/model-runtime/src/core/contextBuilders/google.test.ts +78 -24
  14. package/packages/model-runtime/src/core/contextBuilders/google.ts +10 -2
  15. package/packages/model-runtime/src/core/parameterResolver.ts +3 -0
  16. package/packages/model-runtime/src/core/streams/google/google-ai.test.ts +451 -20
  17. package/packages/model-runtime/src/core/streams/google/index.ts +113 -3
  18. package/packages/model-runtime/src/core/streams/protocol.ts +19 -0
  19. package/packages/types/src/message/common/base.ts +26 -0
  20. package/packages/types/src/message/common/metadata.ts +7 -0
  21. package/packages/utils/src/index.ts +1 -0
  22. package/packages/utils/src/multimodalContent.ts +25 -0
  23. package/src/components/Thinking/index.tsx +3 -3
  24. package/src/features/ChatList/Messages/Assistant/DisplayContent.tsx +44 -0
  25. package/src/features/ChatList/Messages/Assistant/MessageBody.tsx +96 -0
  26. package/src/features/ChatList/Messages/Assistant/Reasoning/index.tsx +26 -13
  27. package/src/features/ChatList/Messages/Assistant/index.tsx +8 -6
  28. package/src/features/ChatList/Messages/Default.tsx +4 -7
  29. package/src/features/ChatList/components/RichContentRenderer.tsx +35 -0
  30. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +244 -17
  31. package/src/features/ChatList/Messages/Assistant/MessageContent.tsx +0 -78
@@ -5,14 +5,17 @@ import { isDesktop } from '@lobechat/const';
5
5
  import {
6
6
  ChatImageItem,
7
7
  ChatToolPayload,
8
+ MessageContentPart,
8
9
  MessageToolCall,
9
10
  ModelUsage,
10
11
  TraceNameMap,
11
12
  UIChatMessage,
12
13
  } from '@lobechat/types';
14
+ import { serializePartsForStorage } from '@lobechat/utils';
13
15
  import debug from 'debug';
14
16
  import { t } from 'i18next';
15
17
  import { throttle } from 'lodash-es';
18
+ import pMap from 'p-map';
16
19
  import { StateCreator } from 'zustand/vanilla';
17
20
 
18
21
  import { createAgentToolsEngine } from '@/helpers/toolEngineering';
@@ -272,14 +275,21 @@ export const streamingExecutor: StateCreator<
272
275
  let finalUsage;
273
276
  let msgTraceId: string | undefined;
274
277
  let output = '';
275
- let thinking = '';
278
+
279
+ let thinkingContent = '';
276
280
  let thinkingStartAt: number;
277
- let duration: number | undefined;
281
+ let thinkingDuration: number | undefined;
278
282
  let reasoningOperationId: string | undefined;
279
283
  let finishType: string | undefined;
280
284
  // to upload image
281
285
  const uploadTasks: Map<string, Promise<{ id?: string; url?: string }>> = new Map();
282
286
 
287
+ // Multimodal content parts
288
+ let contentParts: MessageContentPart[] = [];
289
+ let reasoningParts: MessageContentPart[] = [];
290
+ const contentImageUploads: Map<number, Promise<string>> = new Map();
291
+ const reasoningImageUploads: Map<number, Promise<string>> = new Map();
292
+
283
293
  // Throttle tool_calls updates to prevent excessive re-renders (max once per 300ms)
284
294
  const throttledUpdateToolCalls = throttle(
285
295
  (toolCalls: MessageToolCall[]) => {
@@ -344,7 +354,9 @@ export const streamingExecutor: StateCreator<
344
354
  if (uploadTasks.size > 0) {
345
355
  try {
346
356
  // 等待所有上传任务完成
347
- const uploadResults = await Promise.all(uploadTasks.values());
357
+ const uploadResults = await pMap(Array.from(uploadTasks.values()), (task) => task, {
358
+ concurrency: 5,
359
+ });
348
360
 
349
361
  // 使用上传后的 S3 URL 替换原始图像数据
350
362
  finalImages = uploadResults.filter((i) => !!i.url) as ChatImageItem[];
@@ -353,6 +365,14 @@ export const streamingExecutor: StateCreator<
353
365
  }
354
366
  }
355
367
 
368
+ // Wait for all multimodal image uploads to complete
369
+ // Note: Arrays are already updated in-place when uploads complete
370
+ // Use Promise.allSettled to continue even if some uploads fail
371
+ await Promise.allSettled([
372
+ ...Array.from(contentImageUploads.values()),
373
+ ...Array.from(reasoningImageUploads.values()),
374
+ ]);
375
+
356
376
  let parsedToolCalls = toolCalls;
357
377
  if (parsedToolCalls && parsedToolCalls.length > 0) {
358
378
  // Flush any pending throttled updates before finalizing
@@ -384,18 +404,58 @@ export const streamingExecutor: StateCreator<
384
404
  operationId,
385
405
  );
386
406
 
407
+ // Check if there are any image parts
408
+ const hasContentImages = contentParts.some((part) => part.type === 'image');
409
+ const hasReasoningImages = reasoningParts.some((part) => part.type === 'image');
410
+
411
+ // Determine final content
412
+ // If has images, serialize contentParts; otherwise use accumulated output text
413
+ const finalContent = hasContentImages ? serializePartsForStorage(contentParts) : output;
414
+
415
+ const finalDuration =
416
+ thinkingDuration && !isNaN(thinkingDuration) ? thinkingDuration : undefined;
417
+
418
+ // Determine final reasoning content
419
+ // Priority: reasoningParts (multimodal) > thinkingContent (from reasoning_part text) > reasoning (from old reasoning event)
420
+ let finalReasoning: any = undefined;
421
+ if (hasReasoningImages) {
422
+ // Has images, use multimodal format
423
+ finalReasoning = {
424
+ content: serializePartsForStorage(reasoningParts),
425
+ duration: finalDuration,
426
+ isMultimodal: true,
427
+ };
428
+ } else if (thinkingContent) {
429
+ // Has text from reasoning_part but no images
430
+ finalReasoning = {
431
+ content: thinkingContent,
432
+ duration: finalDuration,
433
+ };
434
+ } else if (reasoning?.content) {
435
+ // Fallback to old reasoning event content
436
+ finalReasoning = {
437
+ ...reasoning,
438
+ duration: finalDuration,
439
+ };
440
+ }
441
+
387
442
  // update the content after fetch result
388
443
  await optimisticUpdateMessageContent(
389
444
  messageId,
390
- content,
445
+ finalContent,
391
446
  {
392
447
  tools,
393
- reasoning: !!reasoning
394
- ? { ...reasoning, duration: duration && !isNaN(duration) ? duration : undefined }
395
- : undefined,
448
+ reasoning: finalReasoning,
396
449
  search: !!grounding?.citations ? grounding : undefined,
397
450
  imageList: finalImages.length > 0 ? finalImages : undefined,
398
- metadata: { ...usage, ...speed, performance: speed, usage, finishType: type },
451
+ metadata: {
452
+ ...usage,
453
+ ...speed,
454
+ performance: speed,
455
+ usage,
456
+ finishType: type,
457
+ ...(hasContentImages && { isMultimodal: true }),
458
+ },
399
459
  },
400
460
  { operationId },
401
461
  );
@@ -457,8 +517,8 @@ export const streamingExecutor: StateCreator<
457
517
  output += chunk.text;
458
518
 
459
519
  // if there is no duration, it means the end of reasoning
460
- if (!duration) {
461
- duration = Date.now() - thinkingStartAt;
520
+ if (!thinkingDuration) {
521
+ thinkingDuration = Date.now() - thinkingStartAt;
462
522
 
463
523
  // Complete reasoning operation if it exists
464
524
  if (reasoningOperationId) {
@@ -480,7 +540,9 @@ export const streamingExecutor: StateCreator<
480
540
  type: 'updateMessage',
481
541
  value: {
482
542
  content: output,
483
- reasoning: !!thinking ? { content: thinking, duration } : undefined,
543
+ reasoning: !!thinkingContent
544
+ ? { content: thinkingContent, duration: thinkingDuration }
545
+ : undefined,
484
546
  },
485
547
  },
486
548
  { operationId },
@@ -505,13 +567,178 @@ export const streamingExecutor: StateCreator<
505
567
  get().associateMessageWithOperation(messageId, reasoningOperationId);
506
568
  }
507
569
 
508
- thinking += chunk.text;
570
+ thinkingContent += chunk.text;
509
571
 
510
572
  internal_dispatchMessage(
511
573
  {
512
574
  id: messageId,
513
575
  type: 'updateMessage',
514
- value: { reasoning: { content: thinking } },
576
+ value: { reasoning: { content: thinkingContent } },
577
+ },
578
+ { operationId },
579
+ );
580
+ break;
581
+ }
582
+
583
+ case 'reasoning_part': {
584
+ // Start reasoning if not started
585
+ if (!thinkingStartAt) {
586
+ thinkingStartAt = Date.now();
587
+
588
+ const { operationId: reasoningOpId } = get().startOperation({
589
+ type: 'reasoning',
590
+ context: { sessionId, topicId, messageId },
591
+ parentOperationId: operationId,
592
+ });
593
+ reasoningOperationId = reasoningOpId;
594
+ get().associateMessageWithOperation(messageId, reasoningOperationId);
595
+ }
596
+
597
+ const { partType, content: partContent, mimeType } = chunk;
598
+
599
+ if (partType === 'text') {
600
+ const lastPart = reasoningParts.at(-1);
601
+
602
+ // If last part is also text, merge chunks together
603
+ if (lastPart?.type === 'text') {
604
+ reasoningParts = [
605
+ ...reasoningParts.slice(0, -1),
606
+ { type: 'text', text: lastPart.text + partContent },
607
+ ];
608
+ } else {
609
+ // Create new text part (first chunk, may contain thoughtSignature)
610
+ reasoningParts = [...reasoningParts, { type: 'text', text: partContent }];
611
+ }
612
+ thinkingContent += partContent;
613
+ } else if (partType === 'image') {
614
+ // Image part - create new array to avoid mutation
615
+ const tempImage = `data:${mimeType};base64,${partContent}`;
616
+ const partIndex = reasoningParts.length;
617
+ const newPart: MessageContentPart = { type: 'image', image: tempImage };
618
+ reasoningParts = [...reasoningParts, newPart];
619
+
620
+ // Start upload task and update array when done
621
+ const uploadTask = getFileStoreState()
622
+ .uploadBase64FileWithProgress(tempImage)
623
+ .then((file) => {
624
+ const url = file?.url || tempImage;
625
+ // Replace the part at index by creating a new array
626
+ const updatedParts = [...reasoningParts];
627
+ updatedParts[partIndex] = { type: 'image', image: url };
628
+ reasoningParts = updatedParts;
629
+ return url;
630
+ })
631
+ .catch((error) => {
632
+ console.error('[reasoning_part] Image upload failed:', error);
633
+ return tempImage;
634
+ });
635
+
636
+ reasoningImageUploads.set(partIndex, uploadTask);
637
+ }
638
+
639
+ // Real-time update with display format
640
+ // Check if there are any image parts to determine if it's multimodal
641
+ const hasReasoningImages = reasoningParts.some((part) => part.type === 'image');
642
+
643
+ internal_dispatchMessage(
644
+ {
645
+ id: messageId,
646
+ type: 'updateMessage',
647
+ value: {
648
+ reasoning: hasReasoningImages
649
+ ? { tempDisplayContent: reasoningParts, isMultimodal: true }
650
+ : { content: thinkingContent },
651
+ },
652
+ },
653
+ { operationId },
654
+ );
655
+ break;
656
+ }
657
+
658
+ case 'content_part': {
659
+ const { partType, content: partContent, mimeType } = chunk;
660
+
661
+ // End reasoning when content starts
662
+ if (!thinkingDuration && reasoningOperationId) {
663
+ thinkingDuration = Date.now() - thinkingStartAt;
664
+ get().completeOperation(reasoningOperationId);
665
+ reasoningOperationId = undefined;
666
+ }
667
+
668
+ if (partType === 'text') {
669
+ const lastPart = contentParts.at(-1);
670
+
671
+ // If last part is also text, merge chunks together
672
+ if (lastPart?.type === 'text') {
673
+ contentParts = [
674
+ ...contentParts.slice(0, -1),
675
+ { type: 'text', text: lastPart.text + partContent },
676
+ ];
677
+ } else {
678
+ // Create new text part (first chunk, may contain thoughtSignature)
679
+ contentParts = [...contentParts, { type: 'text', text: partContent }];
680
+ }
681
+ output += partContent;
682
+ } else if (partType === 'image') {
683
+ // Image part - create new array to avoid mutation
684
+ const tempImage = `data:${mimeType};base64,${partContent}`;
685
+ const partIndex = contentParts.length;
686
+ const newPart: MessageContentPart = {
687
+ type: 'image',
688
+ image: tempImage,
689
+ };
690
+ contentParts = [...contentParts, newPart];
691
+
692
+ // Start upload task and update array when done
693
+ const uploadTask = getFileStoreState()
694
+ .uploadBase64FileWithProgress(tempImage)
695
+ .then((file) => {
696
+ const url = file?.url || tempImage;
697
+ // Replace the part at index by creating a new array
698
+ const updatedParts = [...contentParts];
699
+ updatedParts[partIndex] = {
700
+ type: 'image',
701
+ image: url,
702
+ };
703
+ contentParts = updatedParts;
704
+ return url;
705
+ })
706
+ .catch((error) => {
707
+ console.error('[content_part] Image upload failed:', error);
708
+ return tempImage;
709
+ });
710
+
711
+ contentImageUploads.set(partIndex, uploadTask);
712
+ }
713
+
714
+ // Real-time update with display format
715
+ // Check if there are any image parts to determine if it's multimodal
716
+ const hasContentImages = contentParts.some((part) => part.type === 'image');
717
+
718
+ const hasReasoningImages = reasoningParts.some((part) => part.type === 'image');
719
+
720
+ internal_dispatchMessage(
721
+ {
722
+ id: messageId,
723
+ type: 'updateMessage',
724
+ value: {
725
+ content: output,
726
+ reasoning: hasReasoningImages
727
+ ? {
728
+ tempDisplayContent: reasoningParts,
729
+ isMultimodal: true,
730
+ duration: thinkingDuration,
731
+ }
732
+ : !!thinkingContent
733
+ ? { content: thinkingContent, duration: thinkingDuration }
734
+ : undefined,
735
+ ...(hasContentImages && {
736
+ metadata: {
737
+ isMultimodal: true,
738
+ tempDisplayContent: serializePartsForStorage(contentParts),
739
+ },
740
+ }),
741
+ },
515
742
  },
516
743
  { operationId },
517
744
  );
@@ -525,8 +752,8 @@ export const streamingExecutor: StateCreator<
525
752
  isFunctionCall = true;
526
753
 
527
754
  // Complete reasoning operation if it exists
528
- if (!duration && reasoningOperationId) {
529
- duration = Date.now() - thinkingStartAt;
755
+ if (!thinkingDuration && reasoningOperationId) {
756
+ thinkingDuration = Date.now() - thinkingStartAt;
530
757
  get().completeOperation(reasoningOperationId);
531
758
  reasoningOperationId = undefined;
532
759
  }
@@ -535,8 +762,8 @@ export const streamingExecutor: StateCreator<
535
762
 
536
763
  case 'stop': {
537
764
  // Complete reasoning operation when receiving stop signal
538
- if (!duration && reasoningOperationId) {
539
- duration = Date.now() - thinkingStartAt;
765
+ if (!thinkingDuration && reasoningOperationId) {
766
+ thinkingDuration = Date.now() - thinkingStartAt;
540
767
  get().completeOperation(reasoningOperationId);
541
768
  reasoningOperationId = undefined;
542
769
  }
@@ -1,78 +0,0 @@
1
- import { LOADING_FLAT } from '@lobechat/const';
2
- import { UIChatMessage } from '@lobechat/types';
3
- import { ReactNode, memo } from 'react';
4
- import { Flexbox } from 'react-layout-kit';
5
-
6
- import { useChatStore } from '@/store/chat';
7
- import { aiChatSelectors, messageStateSelectors } from '@/store/chat/selectors';
8
-
9
- import { DefaultMessage } from '../Default';
10
- import ImageFileListViewer from '../User/ImageFileListViewer';
11
- import { CollapsedMessage } from './CollapsedMessage';
12
- import FileChunks from './FileChunks';
13
- import IntentUnderstanding from './IntentUnderstanding';
14
- import Reasoning from './Reasoning';
15
- import SearchGrounding from './SearchGrounding';
16
-
17
- export const AssistantMessageContent = memo<
18
- UIChatMessage & {
19
- editableContent: ReactNode;
20
- }
21
- >(({ id, tools, content, chunksList, search, imageList, ...props }) => {
22
- const [editing, generating, isCollapsed] = useChatStore((s) => [
23
- messageStateSelectors.isMessageEditing(id)(s),
24
- messageStateSelectors.isMessageGenerating(id)(s),
25
- messageStateSelectors.isMessageCollapsed(id)(s),
26
- ]);
27
-
28
- const isToolCallGenerating = generating && (content === LOADING_FLAT || !content) && !!tools;
29
-
30
- const isReasoning = useChatStore(aiChatSelectors.isMessageInReasoning(id));
31
-
32
- const isIntentUnderstanding = useChatStore(aiChatSelectors.isIntentUnderstanding(id));
33
-
34
- const showSearch = !!search && !!search.citations?.length;
35
- const showImageItems = !!imageList && imageList.length > 0;
36
-
37
- // remove \n to avoid empty content
38
- // refs: https://github.com/lobehub/lobe-chat/pull/6153
39
- const showReasoning =
40
- (!!props.reasoning && props.reasoning.content?.trim() !== '') ||
41
- (!props.reasoning && isReasoning);
42
-
43
- const showFileChunks = !!chunksList && chunksList.length > 0;
44
-
45
- if (editing)
46
- return (
47
- <DefaultMessage
48
- content={content}
49
- id={id}
50
- isToolCallGenerating={isToolCallGenerating}
51
- {...props}
52
- />
53
- );
54
-
55
- if (isCollapsed) return <CollapsedMessage content={content} id={id} />;
56
-
57
- return (
58
- <Flexbox gap={8} id={id}>
59
- {showSearch && (
60
- <SearchGrounding citations={search?.citations} searchQueries={search?.searchQueries} />
61
- )}
62
- {showFileChunks && <FileChunks data={chunksList} />}
63
- {showReasoning && <Reasoning {...props.reasoning} id={id} />}
64
- {isIntentUnderstanding ? (
65
- <IntentUnderstanding />
66
- ) : (
67
- <DefaultMessage
68
- addIdOnDOM={false}
69
- content={content}
70
- id={id}
71
- isToolCallGenerating={isToolCallGenerating}
72
- {...props}
73
- />
74
- )}
75
- {showImageItems && <ImageFileListViewer items={imageList} />}
76
- </Flexbox>
77
- );
78
- });