@lobehub/lobehub 2.0.0-next.115 → 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 (24) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/changelog/v1.json +9 -0
  3. package/package.json +1 -1
  4. package/packages/context-engine/src/processors/MessageContent.ts +100 -6
  5. package/packages/context-engine/src/processors/__tests__/MessageContent.test.ts +239 -0
  6. package/packages/fetch-sse/src/fetchSSE.ts +30 -0
  7. package/packages/model-runtime/src/core/contextBuilders/google.test.ts +78 -24
  8. package/packages/model-runtime/src/core/contextBuilders/google.ts +10 -2
  9. package/packages/model-runtime/src/core/streams/google/google-ai.test.ts +451 -20
  10. package/packages/model-runtime/src/core/streams/google/index.ts +113 -3
  11. package/packages/model-runtime/src/core/streams/protocol.ts +19 -0
  12. package/packages/types/src/message/common/base.ts +26 -0
  13. package/packages/types/src/message/common/metadata.ts +7 -0
  14. package/packages/utils/src/index.ts +1 -0
  15. package/packages/utils/src/multimodalContent.ts +25 -0
  16. package/src/components/Thinking/index.tsx +3 -3
  17. package/src/features/ChatList/Messages/Assistant/DisplayContent.tsx +44 -0
  18. package/src/features/ChatList/Messages/Assistant/MessageBody.tsx +96 -0
  19. package/src/features/ChatList/Messages/Assistant/Reasoning/index.tsx +26 -13
  20. package/src/features/ChatList/Messages/Assistant/index.tsx +8 -6
  21. package/src/features/ChatList/Messages/Default.tsx +4 -7
  22. package/src/features/ChatList/components/RichContentRenderer.tsx +35 -0
  23. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +244 -17
  24. package/src/features/ChatList/Messages/Assistant/MessageContent.tsx +0 -78
@@ -77,6 +77,10 @@ export interface StreamProtocolChunk {
77
77
  | 'reasoning_signature'
78
78
  // flagged reasoning signature
79
79
  | 'flagged_reasoning_signature'
80
+ // multimodal content part in reasoning
81
+ | 'reasoning_part'
82
+ // multimodal content part in content
83
+ | 'content_part'
80
84
  // Search or Grounding
81
85
  | 'grounding'
82
86
  // stop signal
@@ -91,6 +95,21 @@ export interface StreamProtocolChunk {
91
95
  | 'data';
92
96
  }
93
97
 
98
+ /**
99
+ * Stream content part chunk data for multimodal support
100
+ */
101
+ export interface StreamPartChunkData {
102
+ content: string;
103
+ // whether this part is in reasoning or regular content
104
+ inReasoning: boolean;
105
+ // image MIME type
106
+ mimeType?: string;
107
+ // text content or base64 image data
108
+ partType: 'text' | 'image';
109
+ // Optional signature for reasoning verification (Google Gemini feature)
110
+ thoughtSignature?: string;
111
+ }
112
+
94
113
  export interface StreamToolCallChunkData {
95
114
  function?: {
96
115
  arguments?: string;
@@ -26,14 +26,40 @@ export interface ChatCitationItem {
26
26
  url: string;
27
27
  }
28
28
 
29
+ /**
30
+ * Message content part types for multimodal content support
31
+ */
32
+ export interface MessageContentPartText {
33
+ text: string;
34
+ thoughtSignature?: string;
35
+ type: 'text';
36
+ }
37
+
38
+ export interface MessageContentPartImage {
39
+ image: string;
40
+ thoughtSignature?: string;
41
+ type: 'image';
42
+ }
43
+
44
+ export type MessageContentPart = MessageContentPartText | MessageContentPartImage;
45
+
29
46
  export interface ModelReasoning {
47
+ /**
48
+ * Reasoning content, can be plain string or serialized JSON array of MessageContentPart[]
49
+ */
30
50
  content?: string;
31
51
  duration?: number;
52
+ /**
53
+ * Flag indicating if content is multimodal (serialized MessageContentPart[])
54
+ */
55
+ isMultimodal?: boolean;
32
56
  signature?: string;
57
+ tempDisplayContent?: MessageContentPart[];
33
58
  }
34
59
 
35
60
  export const ModelReasoningSchema = z.object({
36
61
  content: z.string().optional(),
37
62
  duration: z.number().optional(),
63
+ isMultimodal: z.boolean().optional(),
38
64
  signature: z.string().optional(),
39
65
  });
@@ -78,6 +78,7 @@ export const ModelPerformanceSchema = z.object({
78
78
  export const MessageMetadataSchema = ModelUsageSchema.merge(ModelPerformanceSchema).extend({
79
79
  collapsed: z.boolean().optional(),
80
80
  inspectExpanded: z.boolean().optional(),
81
+ isMultimodal: z.boolean().optional(),
81
82
  });
82
83
 
83
84
  export interface ModelUsage extends ModelTokensUsage {
@@ -123,4 +124,10 @@ export interface MessageMetadata extends ModelUsage, ModelPerformance {
123
124
  compare?: boolean;
124
125
  usage?: ModelUsage;
125
126
  performance?: ModelPerformance;
127
+ /**
128
+ * Flag indicating if message content is multimodal (serialized MessageContentPart[])
129
+ */
130
+ isMultimodal?: boolean;
131
+ // message content is multimodal, display content in the streaming, won't save to db
132
+ tempDisplayContent?: string;
126
133
  }
@@ -5,6 +5,7 @@ export * from './format';
5
5
  export * from './imageToBase64';
6
6
  export * from './keyboard';
7
7
  export * from './merge';
8
+ export * from './multimodalContent';
8
9
  export * from './number';
9
10
  export * from './object';
10
11
  export * from './pricing';
@@ -0,0 +1,25 @@
1
+ import { MessageContentPart } from '@lobechat/types';
2
+
3
+ /**
4
+ * Serialize message content parts to JSON string for storage
5
+ */
6
+ export function serializePartsForStorage(parts: MessageContentPart[]): string {
7
+ return JSON.stringify(parts);
8
+ }
9
+
10
+ /**
11
+ * Deserialize content string to message content parts
12
+ * Returns null if content is not valid JSON array of parts
13
+ */
14
+ export function deserializeParts(content: string): MessageContentPart[] | null {
15
+ try {
16
+ const parsed = JSON.parse(content);
17
+ // Validate it's an array with valid part structure
18
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed[0]?.type) {
19
+ return parsed as MessageContentPart[];
20
+ }
21
+ } catch {
22
+ // Not JSON, treat as plain text
23
+ }
24
+ return null;
25
+ }
@@ -4,7 +4,7 @@ import { createStyles } from 'antd-style';
4
4
  import { AnimatePresence, motion } from 'framer-motion';
5
5
  import { AtomIcon } from 'lucide-react';
6
6
  import { rgba } from 'polished';
7
- import { CSSProperties, RefObject, memo, useEffect, useRef, useState } from 'react';
7
+ import { CSSProperties, ReactNode, RefObject, memo, useEffect, useRef, useState } from 'react';
8
8
  import { useTranslation } from 'react-i18next';
9
9
  import { Flexbox } from 'react-layout-kit';
10
10
 
@@ -76,7 +76,7 @@ const useStyles = createStyles(({ css, token }) => ({
76
76
 
77
77
  interface ThinkingProps {
78
78
  citations?: ChatCitationItem[];
79
- content?: string;
79
+ content?: string | ReactNode;
80
80
  duration?: number;
81
81
  style?: CSSProperties;
82
82
  thinking?: boolean;
@@ -158,7 +158,7 @@ const Thinking = memo<ThinkingProps>((props) => {
158
158
  </Flexbox>
159
159
  )}
160
160
  <Flexbox gap={4} horizontal>
161
- {showDetail && content && (
161
+ {showDetail && content && typeof content === 'string' && (
162
162
  <div
163
163
  onClick={(event) => {
164
164
  event.stopPropagation();
@@ -0,0 +1,44 @@
1
+ import { deserializeParts } from '@lobechat/utils';
2
+ import { Markdown, MarkdownProps } from '@lobehub/ui';
3
+ import { memo } from 'react';
4
+
5
+ import BubblesLoading from '@/components/BubblesLoading';
6
+ import { LOADING_FLAT } from '@/const/message';
7
+ import { RichContentRenderer } from '@/features/ChatList/components/RichContentRenderer';
8
+ import { normalizeThinkTags, processWithArtifact } from '@/features/ChatList/utils/markdown';
9
+
10
+ const MessageContent = memo<{
11
+ addIdOnDOM?: boolean;
12
+ content: string;
13
+ hasImages?: boolean;
14
+ isMultimodal?: boolean;
15
+ isToolCallGenerating?: boolean;
16
+ markdownProps?: Omit<MarkdownProps, 'className' | 'style' | 'children'>;
17
+ tempDisplayContent?: string;
18
+ }>(
19
+ ({
20
+ markdownProps,
21
+ content,
22
+ isToolCallGenerating,
23
+ hasImages,
24
+ isMultimodal,
25
+ tempDisplayContent,
26
+ }) => {
27
+ const message = normalizeThinkTags(processWithArtifact(content));
28
+ if (isToolCallGenerating) return;
29
+
30
+ if ((!content && !hasImages) || content === LOADING_FLAT) return <BubblesLoading />;
31
+
32
+ const contentParts = isMultimodal ? deserializeParts(tempDisplayContent || content) : null;
33
+
34
+ return contentParts ? (
35
+ <RichContentRenderer parts={contentParts} />
36
+ ) : (
37
+ <Markdown {...markdownProps} variant={'chat'}>
38
+ {message}
39
+ </Markdown>
40
+ );
41
+ },
42
+ );
43
+
44
+ export default MessageContent;
@@ -0,0 +1,96 @@
1
+ import { LOADING_FLAT } from '@lobechat/const';
2
+ import { UIChatMessage } from '@lobechat/types';
3
+ import { MarkdownProps } from '@lobehub/ui';
4
+ import { ReactNode, memo } from 'react';
5
+ import { Flexbox } from 'react-layout-kit';
6
+
7
+ import { useChatStore } from '@/store/chat';
8
+ import { aiChatSelectors, messageStateSelectors } from '@/store/chat/selectors';
9
+
10
+ import { DefaultMessage } from '../Default';
11
+ import ImageFileListViewer from '../User/ImageFileListViewer';
12
+ import { CollapsedMessage } from './CollapsedMessage';
13
+ import MessageContent from './DisplayContent';
14
+ import FileChunks from './FileChunks';
15
+ import IntentUnderstanding from './IntentUnderstanding';
16
+ import Reasoning from './Reasoning';
17
+ import SearchGrounding from './SearchGrounding';
18
+
19
+ export const AssistantMessageBody = memo<
20
+ UIChatMessage & {
21
+ editableContent: ReactNode;
22
+ markdownProps?: Omit<MarkdownProps, 'className' | 'style' | 'children'>;
23
+ }
24
+ >(
25
+ ({
26
+ id,
27
+ tools,
28
+ content,
29
+ chunksList,
30
+ search,
31
+ imageList,
32
+ metadata,
33
+ editableContent,
34
+ markdownProps,
35
+ ...props
36
+ }) => {
37
+ const [editing, generating, isCollapsed] = useChatStore((s) => [
38
+ messageStateSelectors.isMessageEditing(id)(s),
39
+ messageStateSelectors.isMessageGenerating(id)(s),
40
+ messageStateSelectors.isMessageCollapsed(id)(s),
41
+ ]);
42
+
43
+ const isToolCallGenerating = generating && (content === LOADING_FLAT || !content) && !!tools;
44
+
45
+ const isReasoning = useChatStore(aiChatSelectors.isMessageInReasoning(id));
46
+
47
+ const isIntentUnderstanding = useChatStore(aiChatSelectors.isIntentUnderstanding(id));
48
+
49
+ const showSearch = !!search && !!search.citations?.length;
50
+ const showImageItems = !!imageList && imageList.length > 0;
51
+
52
+ // remove \n to avoid empty content
53
+ // refs: https://github.com/lobehub/lobe-chat/pull/6153
54
+ const showReasoning =
55
+ (!!props.reasoning && props.reasoning.content?.trim() !== '') ||
56
+ (!props.reasoning && isReasoning);
57
+
58
+ const showFileChunks = !!chunksList && chunksList.length > 0;
59
+
60
+ if (editing)
61
+ return (
62
+ <DefaultMessage
63
+ content={content}
64
+ editableContent={editableContent}
65
+ id={id}
66
+ isToolCallGenerating={isToolCallGenerating}
67
+ {...props}
68
+ />
69
+ );
70
+
71
+ if (isCollapsed) return <CollapsedMessage content={content} id={id} />;
72
+
73
+ return (
74
+ <Flexbox gap={8} id={id}>
75
+ {showSearch && (
76
+ <SearchGrounding citations={search?.citations} searchQueries={search?.searchQueries} />
77
+ )}
78
+ {showFileChunks && <FileChunks data={chunksList} />}
79
+ {showReasoning && <Reasoning {...props.reasoning} id={id} />}
80
+ {isIntentUnderstanding ? (
81
+ <IntentUnderstanding />
82
+ ) : (
83
+ <MessageContent
84
+ content={content}
85
+ hasImages={showImageItems}
86
+ isMultimodal={metadata?.isMultimodal}
87
+ isToolCallGenerating={isToolCallGenerating}
88
+ markdownProps={markdownProps}
89
+ tempDisplayContent={metadata?.tempDisplayContent}
90
+ />
91
+ )}
92
+ {showImageItems && <ImageFileListViewer items={imageList} />}
93
+ </Flexbox>
94
+ );
95
+ },
96
+ );
@@ -1,3 +1,5 @@
1
+ import { MessageContentPart } from '@lobechat/types';
2
+ import { deserializeParts } from '@lobechat/utils';
1
3
  import { memo } from 'react';
2
4
 
3
5
  import Thinking from '@/components/Thinking';
@@ -6,24 +8,35 @@ import { aiChatSelectors } from '@/store/chat/selectors';
6
8
  import { useUserStore } from '@/store/user';
7
9
  import { userGeneralSettingsSelectors } from '@/store/user/selectors';
8
10
 
11
+ import { RichContentRenderer } from '../../../components/RichContentRenderer';
12
+
9
13
  interface ReasoningProps {
10
14
  content?: string;
11
15
  duration?: number;
12
16
  id: string;
17
+ isMultimodal?: boolean;
18
+ tempDisplayContent?: MessageContentPart[];
13
19
  }
14
20
 
15
- const Reasoning = memo<ReasoningProps>(({ content = '', duration, id }) => {
16
- const isReasoning = useChatStore(aiChatSelectors.isMessageInReasoning(id));
17
- const transitionMode = useUserStore(userGeneralSettingsSelectors.transitionMode);
18
-
19
- return (
20
- <Thinking
21
- content={content}
22
- duration={duration}
23
- thinking={isReasoning}
24
- thinkingAnimated={transitionMode === 'fadeIn' && isReasoning}
25
- />
26
- );
27
- });
21
+ const Reasoning = memo<ReasoningProps>(
22
+ ({ content = '', duration, id, isMultimodal, tempDisplayContent }) => {
23
+ const isReasoning = useChatStore(aiChatSelectors.isMessageInReasoning(id));
24
+ const transitionMode = useUserStore(userGeneralSettingsSelectors.transitionMode);
25
+
26
+ const parts = tempDisplayContent || deserializeParts(content);
27
+
28
+ // If parts are provided, render multimodal content
29
+ const thinkingContent = isMultimodal && parts ? <RichContentRenderer parts={parts} /> : content;
30
+
31
+ return (
32
+ <Thinking
33
+ content={thinkingContent}
34
+ duration={duration}
35
+ thinking={isReasoning}
36
+ thinkingAnimated={transitionMode === 'fadeIn' && isReasoning}
37
+ />
38
+ );
39
+ },
40
+ );
28
41
 
29
42
  export default Reasoning;
@@ -33,7 +33,7 @@ import { useDoubleClickEdit } from '../../hooks/useDoubleClickEdit';
33
33
  import { normalizeThinkTags, processWithArtifact } from '../../utils/markdown';
34
34
  import { AssistantActionsBar } from './Actions';
35
35
  import { AssistantMessageExtra } from './Extra';
36
- import { AssistantMessageContent } from './MessageContent';
36
+ import { AssistantMessageBody } from './MessageBody';
37
37
 
38
38
  const rehypePlugins = markdownElements.map((element) => element.rehypePlugin).filter(Boolean);
39
39
  const remarkPlugins = markdownElements.map((element) => element.remarkPlugin).filter(Boolean);
@@ -75,7 +75,7 @@ export const useStyles = createStyles(
75
75
  justify-content: ${placement === 'left' ? 'flex-end' : 'flex-start'};
76
76
  `,
77
77
  editing &&
78
- css`
78
+ css`
79
79
  pointer-events: none !important;
80
80
  opacity: 0 !important;
81
81
  `,
@@ -84,11 +84,9 @@ export const useStyles = createStyles(
84
84
  variant === 'docs' && rawContainerStylish,
85
85
  css`
86
86
  position: relative;
87
-
88
87
  width: 100%;
89
88
  max-width: 100vw;
90
89
  padding-block: 24px 12px;
91
- padding-inline: 12px;
92
90
 
93
91
  @supports (content-visibility: auto) {
94
92
  contain-intrinsic-size: auto 100lvh;
@@ -305,9 +303,13 @@ const AssistantMessage = memo<AssistantMessageProps>(
305
303
 
306
304
  const renderMessage = useCallback(
307
305
  (editableContent: ReactNode) => (
308
- <AssistantMessageContent {...item} editableContent={editableContent} />
306
+ <AssistantMessageBody
307
+ {...item}
308
+ editableContent={editableContent}
309
+ markdownProps={markdownProps}
310
+ />
309
311
  ),
310
- [item],
312
+ [item, markdownProps],
311
313
  );
312
314
  const errorMessage = <ErrorMessageExtra data={item} />;
313
315
 
@@ -6,25 +6,22 @@ import { LOADING_FLAT } from '@/const/message';
6
6
  import { useChatStore } from '@/store/chat';
7
7
  import { messageStateSelectors } from '@/store/chat/selectors';
8
8
 
9
- export const MessageContentClassName = 'msg_content_flag'
9
+ export const MessageContentClassName = 'msg_content_flag';
10
10
 
11
11
  export const DefaultMessage = memo<
12
12
  UIChatMessage & {
13
13
  addIdOnDOM?: boolean;
14
14
  editableContent: ReactNode;
15
+ hasImages?: boolean;
15
16
  isToolCallGenerating?: boolean;
16
17
  }
17
- >(({ id, editableContent, content, isToolCallGenerating, addIdOnDOM = true }) => {
18
+ >(({ id, editableContent, content, isToolCallGenerating, addIdOnDOM = true, hasImages }) => {
18
19
  const editing = useChatStore(messageStateSelectors.isMessageEditing(id));
19
20
 
20
21
  if (isToolCallGenerating) return;
21
22
 
22
- if (!content) return <BubblesLoading />;
23
+ if (!content && !hasImages) return <BubblesLoading />;
23
24
  if (content === LOADING_FLAT && !editing) return <BubblesLoading />;
24
25
 
25
26
  return <div id={addIdOnDOM ? id : undefined}>{editableContent}</div>;
26
27
  });
27
-
28
- export const DefaultBelowMessage = memo<UIChatMessage>(() => {
29
- return null;
30
- });
@@ -0,0 +1,35 @@
1
+ import { Image, Markdown } from '@lobehub/ui';
2
+ import { memo } from 'react';
3
+ import { Flexbox } from 'react-layout-kit';
4
+
5
+ import { MessageContentPart } from '@/types/index';
6
+
7
+ interface RichContentRendererProps {
8
+ parts: MessageContentPart[];
9
+ }
10
+
11
+ export const RichContentRenderer = memo<RichContentRendererProps>(({ parts }) => {
12
+ return (
13
+ <Flexbox gap={8}>
14
+ {parts.map((part, index) => {
15
+ if (part.type === 'text') {
16
+ return (
17
+ <Markdown key={index} variant="chat">
18
+ {part.text}
19
+ </Markdown>
20
+ );
21
+ }
22
+
23
+ if (part.type === 'image') {
24
+ return (
25
+ <Image key={index} src={part.image} style={{ borderRadius: 8, maxWidth: '100%' }} />
26
+ );
27
+ }
28
+
29
+ return null;
30
+ })}
31
+ </Flexbox>
32
+ );
33
+ });
34
+
35
+ RichContentRenderer.displayName = 'RichContentRenderer';