@lobehub/lobehub 2.0.0-next.65 → 2.0.0-next.67

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 (70) hide show
  1. package/.github/workflows/claude-translator.yml +1 -0
  2. package/CHANGELOG.md +50 -0
  3. package/changelog/v1.json +18 -0
  4. package/locales/ar/chat.json +3 -0
  5. package/locales/bg-BG/chat.json +3 -0
  6. package/locales/de-DE/chat.json +3 -0
  7. package/locales/en-US/chat.json +3 -0
  8. package/locales/es-ES/chat.json +3 -0
  9. package/locales/fa-IR/chat.json +3 -0
  10. package/locales/fr-FR/chat.json +3 -0
  11. package/locales/it-IT/chat.json +3 -0
  12. package/locales/ja-JP/chat.json +3 -0
  13. package/locales/ko-KR/chat.json +3 -0
  14. package/locales/nl-NL/chat.json +3 -0
  15. package/locales/pl-PL/chat.json +3 -0
  16. package/locales/pt-BR/chat.json +3 -0
  17. package/locales/ru-RU/chat.json +3 -0
  18. package/locales/tr-TR/chat.json +3 -0
  19. package/locales/vi-VN/chat.json +3 -0
  20. package/locales/zh-CN/chat.json +3 -0
  21. package/locales/zh-TW/chat.json +3 -0
  22. package/package.json +7 -6
  23. package/packages/conversation-flow/src/__tests__/fixtures/index.ts +4 -8
  24. package/packages/conversation-flow/src/__tests__/fixtures/inputs/{assistant-with-tools.json → assistantGroup/assistant-with-tools.json} +2 -1
  25. package/packages/conversation-flow/src/__tests__/fixtures/inputs/assistantGroup/index.ts +8 -0
  26. package/packages/conversation-flow/src/__tests__/fixtures/inputs/index.ts +2 -4
  27. package/packages/conversation-flow/src/__tests__/fixtures/outputs/{assistant-with-tools.json → assistantGroup/assistant-with-tools.json} +8 -8
  28. package/packages/conversation-flow/src/__tests__/fixtures/outputs/assistantGroup/index.ts +8 -0
  29. package/packages/conversation-flow/src/__tests__/parse.test.ts +6 -6
  30. package/packages/conversation-flow/src/parse.ts +45 -1
  31. package/packages/conversation-flow/src/transformation/FlatListBuilder.ts +64 -0
  32. package/packages/database/package.json +2 -2
  33. package/packages/obervability-otel/package.json +1 -1
  34. package/packages/types/src/message/common/metadata.ts +8 -1
  35. package/packages/types/src/message/ui/chat.ts +1 -0
  36. package/src/app/(backend)/market/agent/[[...segments]]/route.ts +1 -1
  37. package/src/app/(backend)/market/oidc/[[...segments]]/route.ts +1 -1
  38. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/ChatItem/index.tsx +1 -0
  39. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatMinimap/index.tsx +21 -28
  40. package/src/app/market-auth-callback/layout.tsx +27 -3
  41. package/src/features/ChatInput/ActionBar/Token/TokenTag.tsx +2 -2
  42. package/src/features/ChatItem/style.ts +4 -0
  43. package/src/features/Conversation/Messages/Assistant/Actions/index.tsx +18 -4
  44. package/src/features/Conversation/Messages/Assistant/CollapsedMessage.tsx +37 -0
  45. package/src/features/Conversation/Messages/Assistant/MessageContent.tsx +16 -9
  46. package/src/features/Conversation/Messages/Assistant/index.tsx +329 -230
  47. package/src/features/Conversation/Messages/Group/Actions/WithContentId.tsx +31 -9
  48. package/src/features/Conversation/Messages/Group/CollapsedMessage.tsx +37 -0
  49. package/src/features/Conversation/Messages/Group/{GroupChildren.tsx → Group.tsx} +18 -4
  50. package/src/features/Conversation/Messages/Group/GroupItem.tsx +3 -5
  51. package/src/features/Conversation/Messages/Group/index.tsx +84 -19
  52. package/src/features/Conversation/Messages/User/Actions/ActionsBar.tsx +3 -3
  53. package/src/features/Conversation/Messages/index.tsx +24 -8
  54. package/src/features/Conversation/components/VirtualizedList/VirtuosoContext.ts +13 -13
  55. package/src/features/Conversation/components/VirtualizedList/index.tsx +92 -58
  56. package/src/features/Conversation/components/WideScreenContainer/index.tsx +10 -6
  57. package/src/features/Conversation/hooks/useChatListActionsBar.tsx +14 -0
  58. package/src/features/Conversation/hooks/useDoubleClickEdit.ts +3 -3
  59. package/src/layout/AuthProvider/MarketAuth/MarketAuthProvider.tsx +1 -1
  60. package/src/libs/mcp/__tests__/index.test.ts +6 -6
  61. package/src/locales/default/chat.ts +3 -0
  62. package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +9 -1
  63. package/src/store/chat/slices/message/actions/publicApi.ts +17 -0
  64. package/src/store/chat/slices/message/selectors/displayMessage.ts +1 -1
  65. package/src/store/chat/slices/message/selectors/messageState.ts +7 -0
  66. package/src/store/chat/slices/translate/action.test.ts +26 -32
  67. package/src/store/chat/slices/translate/action.ts +3 -3
  68. /package/packages/conversation-flow/src/__tests__/fixtures/inputs/{complex-scenario.json → assistantGroup/tools-with-branches.json} +0 -0
  69. /package/packages/conversation-flow/src/__tests__/fixtures/outputs/{complex-scenario.json → assistantGroup/tools-with-branches.json} +0 -0
  70. /package/src/features/Conversation/Messages/Group/{GroupContext.tsx → GroupContext.ts} +0 -0
@@ -1,18 +1,8 @@
1
1
  'use client';
2
2
 
3
3
  import isEqual from 'fast-deep-equal';
4
- import {
5
- ReactNode,
6
- forwardRef,
7
- memo,
8
- useCallback,
9
- useEffect,
10
- useMemo,
11
- useRef,
12
- useState,
13
- } from 'react';
14
- import { Flexbox } from 'react-layout-kit';
15
- import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
4
+ import { ReactNode, memo, useCallback, useEffect, useRef, useState } from 'react';
5
+ import { VList, VListHandle } from 'virtua';
16
6
 
17
7
  import WideScreenContainer from '@/features/Conversation/components/WideScreenContainer';
18
8
  import { useChatStore } from '@/store/chat';
@@ -20,11 +10,7 @@ import { displayMessageSelectors } from '@/store/chat/selectors';
20
10
 
21
11
  import AutoScroll from '../AutoScroll';
22
12
  import SkeletonList from '../SkeletonList';
23
- import {
24
- VirtuosoContext,
25
- resetVirtuosoVisibleItems,
26
- setVirtuosoGlobalRef,
27
- } from './VirtuosoContext';
13
+ import { VirtuaContext, resetVirtuaVisibleItems, setVirtuaGlobalRef } from './VirtuosoContext';
28
14
 
29
15
  interface VirtualizedListProps {
30
16
  dataSource: string[];
@@ -32,78 +18,124 @@ interface VirtualizedListProps {
32
18
  mobile?: boolean;
33
19
  }
34
20
 
35
- const List = forwardRef(({ ...props }, ref) => {
36
- return (
37
- <Flexbox>
38
- <WideScreenContainer id={'chatlist-list'} ref={ref} {...props} />
39
- </Flexbox>
40
- );
41
- });
42
-
43
21
  const VirtualizedList = memo<VirtualizedListProps>(({ mobile, dataSource, itemContent }) => {
44
- const virtuosoRef = useRef<VirtuosoHandle>(null);
22
+ const virtuaRef = useRef<VListHandle>(null);
45
23
  const prevDataLengthRef = useRef(dataSource.length);
46
24
  const [atBottom, setAtBottom] = useState(true);
47
25
  const [isScrolling, setIsScrolling] = useState(false);
26
+ // eslint-disable-next-line no-undef
27
+ const scrollEndTimerRef = useRef<NodeJS.Timeout | null>(null);
48
28
 
49
29
  const [isFirstLoading, isCurrentChatLoaded] = useChatStore((s) => [
50
30
  displayMessageSelectors.currentChatLoadingState(s),
51
31
  displayMessageSelectors.isCurrentDisplayChatLoaded(s),
52
32
  ]);
53
33
 
54
- const getFollowOutput = useCallback(() => {
55
- const newFollowOutput = dataSource.length > prevDataLengthRef.current ? 'auto' : false;
34
+ const atBottomThreshold = 200 * (mobile ? 2 : 1);
35
+
36
+ // Check if at bottom based on scroll position
37
+ const checkAtBottom = useCallback(() => {
38
+ const ref = virtuaRef.current;
39
+ if (!ref) return false;
40
+
41
+ const scrollOffset = ref.scrollOffset;
42
+ const scrollSize = ref.scrollSize;
43
+ const viewportSize = ref.viewportSize;
44
+
45
+ return scrollSize - scrollOffset - viewportSize <= atBottomThreshold;
46
+ }, [atBottomThreshold]);
47
+
48
+ // Handle scroll events
49
+ const handleScroll = useCallback(() => {
50
+ setIsScrolling(true);
51
+
52
+ // Check if at bottom
53
+ const isAtBottom = checkAtBottom();
54
+ setAtBottom(isAtBottom);
55
+
56
+ // Clear existing timer
57
+ if (scrollEndTimerRef.current) {
58
+ clearTimeout(scrollEndTimerRef.current);
59
+ }
60
+
61
+ // Set new timer for scroll end
62
+ scrollEndTimerRef.current = setTimeout(() => {
63
+ setIsScrolling(false);
64
+ }, 150);
65
+ }, [checkAtBottom]);
66
+
67
+ const handleScrollEnd = useCallback(() => {
68
+ setIsScrolling(false);
69
+ }, []);
70
+
71
+ // Auto scroll to bottom when new messages arrive
72
+ useEffect(() => {
73
+ const shouldScroll = dataSource.length > prevDataLengthRef.current;
56
74
  prevDataLengthRef.current = dataSource.length;
57
- return newFollowOutput;
75
+
76
+ if (shouldScroll && virtuaRef.current) {
77
+ virtuaRef.current.scrollToIndex(dataSource.length - 2, { align: 'start', smooth: true });
78
+ }
58
79
  }, [dataSource.length]);
59
80
 
60
81
  const scrollToBottom = useCallback(
61
82
  (behavior: 'auto' | 'smooth' = 'smooth') => {
62
83
  if (atBottom) return;
63
- if (!virtuosoRef.current) return;
64
- virtuosoRef.current.scrollToIndex({ align: 'end', behavior, index: 'LAST' });
84
+ if (!virtuaRef.current) return;
85
+ virtuaRef.current.scrollToIndex(dataSource.length - 1, {
86
+ align: 'end',
87
+ smooth: behavior === 'smooth',
88
+ });
65
89
  },
66
- [atBottom],
90
+ [atBottom, dataSource.length],
67
91
  );
68
92
 
69
- const components = useMemo(() => ({ List }), []);
70
- const computeItemKey = useCallback((index: number, item: string) => item, []);
71
-
72
93
  useEffect(() => {
73
- setVirtuosoGlobalRef(virtuosoRef);
94
+ setVirtuaGlobalRef(virtuaRef);
74
95
 
75
96
  return () => {
76
- setVirtuosoGlobalRef(null);
97
+ setVirtuaGlobalRef(null);
77
98
  };
78
- }, [virtuosoRef]);
99
+ }, [virtuaRef]);
79
100
 
80
101
  useEffect(() => {
81
102
  return () => {
82
- resetVirtuosoVisibleItems();
103
+ resetVirtuaVisibleItems();
104
+ if (scrollEndTimerRef.current) {
105
+ clearTimeout(scrollEndTimerRef.current);
106
+ }
83
107
  };
84
108
  }, []);
85
109
 
86
- // overscan should be 2 times the height of the window
87
- const overscan = typeof window !== 'undefined' ? window.innerHeight * 2 : 0;
110
+ // Scroll to bottom on initial render
111
+ useEffect(() => {
112
+ if (virtuaRef.current && dataSource.length > 0) {
113
+ virtuaRef.current.scrollToIndex(dataSource.length - 1, { align: 'end' });
114
+ }
115
+ }, [isCurrentChatLoaded]);
88
116
 
89
117
  // first time loading or not loaded
90
118
  if (isFirstLoading || !isCurrentChatLoaded) return <SkeletonList mobile={mobile} />;
91
119
 
92
120
  return (
93
- <VirtuosoContext value={virtuosoRef}>
94
- <Virtuoso
95
- atBottomStateChange={setAtBottom}
96
- atBottomThreshold={200 * (mobile ? 2 : 1)}
97
- components={components}
98
- computeItemKey={computeItemKey}
121
+ <VirtuaContext value={virtuaRef}>
122
+ <VList
123
+ // bufferSize should be 2 times the height of the window
124
+ bufferSize={typeof window !== 'undefined' ? window.innerHeight : 0}
99
125
  data={dataSource}
100
- followOutput={getFollowOutput}
101
- increaseViewportBy={overscan}
102
- initialTopMostItemIndex={dataSource?.length - 1}
103
- isScrolling={setIsScrolling}
104
- itemContent={itemContent}
105
- ref={virtuosoRef}
106
- />
126
+ onScroll={handleScroll}
127
+ onScrollEnd={handleScrollEnd}
128
+ ref={virtuaRef}
129
+ reverse
130
+ style={{ height: '100%' }}
131
+ >
132
+ {(data, index) => (
133
+ <WideScreenContainer key={data} style={{ position: 'relative' }}>
134
+ {itemContent(index, data, { virtuaRef })}
135
+ </WideScreenContainer>
136
+ )}
137
+ </VList>
138
+
107
139
  <WideScreenContainer
108
140
  onChange={() => {
109
141
  if (!atBottom) return;
@@ -117,21 +149,23 @@ const VirtualizedList = memo<VirtualizedListProps>(({ mobile, dataSource, itemCo
117
149
  atBottom={atBottom}
118
150
  isScrolling={isScrolling}
119
151
  onScrollToBottom={(type) => {
120
- const virtuoso = virtuosoRef.current;
152
+ const virtua = virtuaRef.current;
153
+ if (!virtua) return;
154
+
121
155
  switch (type) {
122
156
  case 'auto': {
123
- virtuoso?.scrollToIndex({ align: 'end', behavior: 'auto', index: 'LAST' });
157
+ virtua.scrollToIndex(dataSource.length - 1, { align: 'end' });
124
158
  break;
125
159
  }
126
160
  case 'click': {
127
- virtuoso?.scrollToIndex({ align: 'end', behavior: 'smooth', index: 'LAST' });
161
+ virtua.scrollToIndex(dataSource.length - 1, { align: 'end', smooth: true });
128
162
  break;
129
163
  }
130
164
  }
131
165
  }}
132
166
  />
133
167
  </WideScreenContainer>
134
- </VirtuosoContext>
168
+ </VirtuaContext>
135
169
  );
136
170
  }, isEqual);
137
171
 
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { createStyles } from 'antd-style';
4
+ import isEqual from 'fast-deep-equal';
4
5
  import { memo, useEffect } from 'react';
5
6
  import { Flexbox, FlexboxProps } from 'react-layout-kit';
6
7
 
@@ -32,15 +33,18 @@ const WideScreenContainer = memo<WideScreenContainerProps>(
32
33
  }, [wideScreen]);
33
34
 
34
35
  return (
35
- <Flexbox
36
- className={cx(styles.container, className)}
37
- width={wideScreen ? '100%' : `min(${CONVERSATION_MIN_WIDTH}px, 100%)`}
38
- {...rest}
39
- >
40
- {children}
36
+ <Flexbox width={'100%'}>
37
+ <Flexbox
38
+ className={cx(styles.container, className)}
39
+ width={wideScreen ? '100%' : `min(${CONVERSATION_MIN_WIDTH}px, 100%)`}
40
+ {...rest}
41
+ >
42
+ {children}
43
+ </Flexbox>
41
44
  </Flexbox>
42
45
  );
43
46
  },
47
+ isEqual,
44
48
  );
45
49
 
46
50
  export default WideScreenContainer;
@@ -6,6 +6,8 @@ import {
6
6
  DownloadIcon,
7
7
  Edit,
8
8
  LanguagesIcon,
9
+ ListChevronsDownUp,
10
+ ListChevronsUpDown,
9
11
  ListRestart,
10
12
  Play,
11
13
  RotateCcw,
@@ -27,12 +29,14 @@ const translateStyle = css`
27
29
 
28
30
  interface ChatListActionsBar {
29
31
  branching: ActionIconGroupItemType;
32
+ collapse: ActionIconGroupItemType;
30
33
  continueGeneration: ActionIconGroupItemType;
31
34
  copy: ActionIconGroupItemType;
32
35
  del: ActionIconGroupItemType;
33
36
  delAndRegenerate: ActionIconGroupItemType;
34
37
  divider: { type: 'divider' };
35
38
  edit: ActionIconGroupItemType;
39
+ expand: ActionIconGroupItemType;
36
40
  export: ActionIconGroupItemType;
37
41
  regenerate: ActionIconGroupItemType;
38
42
  share: ActionIconGroupItemType;
@@ -58,6 +62,11 @@ export const useChatListActionsBar = ({
58
62
  key: 'branching',
59
63
  label: t('branching'),
60
64
  },
65
+ collapse: {
66
+ icon: ListChevronsDownUp,
67
+ key: 'collapse',
68
+ label: t('messageAction.collapse', { ns: 'chat' }),
69
+ },
61
70
  continueGeneration: {
62
71
  disabled: isContinuing,
63
72
  icon: ArrowDownFromLine,
@@ -93,6 +102,11 @@ export const useChatListActionsBar = ({
93
102
  key: 'edit',
94
103
  label: t('edit'),
95
104
  },
105
+ expand: {
106
+ icon: ListChevronsUpDown,
107
+ key: 'expand',
108
+ label: t('messageAction.expand', { ns: 'chat' }),
109
+ },
96
110
  export: {
97
111
  icon: DownloadIcon,
98
112
  key: 'export',
@@ -2,7 +2,7 @@ import { MouseEventHandler, use, useCallback } from 'react';
2
2
 
3
3
  import { useChatStore } from '@/store/chat';
4
4
 
5
- import { VirtuosoContext } from '../components/VirtualizedList/VirtuosoContext';
5
+ import { VirtuaContext } from '../components/VirtualizedList/VirtuosoContext';
6
6
 
7
7
  interface UseDoubleClickEditProps {
8
8
  disableEditing?: boolean;
@@ -20,7 +20,7 @@ export const useDoubleClickEdit = ({
20
20
  index,
21
21
  }: UseDoubleClickEditProps) => {
22
22
  const [toggleMessageEditing] = useChatStore((s) => [s.toggleMessageEditing]);
23
- const virtuosoRef = use(VirtuosoContext);
23
+ const virtuaRef = use(VirtuaContext);
24
24
 
25
25
  return useCallback<MouseEventHandler<HTMLDivElement>>(
26
26
  (e) => {
@@ -35,7 +35,7 @@ export const useDoubleClickEdit = ({
35
35
 
36
36
  toggleMessageEditing(id, true);
37
37
 
38
- virtuosoRef?.current?.scrollIntoView({ align: 'start', behavior: 'auto', index });
38
+ virtuaRef?.current?.scrollToIndex(index, { align: 'start' });
39
39
  },
40
40
  [role, disableEditing],
41
41
  );
@@ -106,7 +106,7 @@ export const MarketAuthProvider = ({ children, isDesktop }: MarketAuthProviderPr
106
106
  // 初始化 OIDC 客户端(仅在客户端)
107
107
  useEffect(() => {
108
108
  if (typeof window !== 'undefined') {
109
- const baseUrl = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'http://127.0.0.1:8787';
109
+ const baseUrl = process.env.NEXT_PUBLIC_MARKET_BASE_URL || 'https://market.lobehub.com';
110
110
  const desktopRedirectUri = new URL(MARKET_OIDC_ENDPOINTS.desktopCallback, baseUrl).toString();
111
111
 
112
112
  // 桌面端使用 Market 手动维护的 Web 回调,Web 端使用当前域名
@@ -21,16 +21,16 @@ describe('MCPClient', () => {
21
21
  await mcpClient.initialize();
22
22
  // Add a small delay to allow the server process to fully start (optional, but can help)
23
23
  await new Promise((resolve) => setTimeout(resolve, 100));
24
- });
24
+ }, 30000);
25
25
 
26
26
  afterEach(async () => {
27
27
  // Assume SDK client/transport handles process termination gracefully
28
28
  // If processes leak, more explicit cleanup might be needed here
29
- });
29
+ }, 30000);
30
30
 
31
31
  it('should create and initialize an instance with stdio transport', () => {
32
32
  expect(mcpClient).toBeInstanceOf(MCPClient);
33
- });
33
+ }, 30000);
34
34
 
35
35
  it('should list tools via stdio', async () => {
36
36
  const result = await mcpClient.listTools();
@@ -40,7 +40,7 @@ describe('MCPClient', () => {
40
40
 
41
41
  // Expect the tools defined in mock-sdk-server.ts
42
42
  expect(result).toMatchSnapshot();
43
- });
43
+ }, 30000);
44
44
 
45
45
  it('should call the "echo" tool via stdio', async () => {
46
46
  const toolName = 'echo';
@@ -52,7 +52,7 @@ describe('MCPClient', () => {
52
52
 
53
53
  const result = await mcpClient.callTool(toolName, toolArgs);
54
54
  expect(result).toEqual(expectedResult);
55
- });
55
+ }, 30000);
56
56
 
57
57
  it('should call the "add" tool via stdio', async () => {
58
58
  const toolName = 'add';
@@ -62,7 +62,7 @@ describe('MCPClient', () => {
62
62
  expect(result).toEqual({
63
63
  content: [{ type: 'text', text: 'The sum is: 12' }],
64
64
  });
65
- });
65
+ }, 30000);
66
66
  });
67
67
 
68
68
  // Error Handling tests remain the same...
@@ -18,6 +18,7 @@ export default {
18
18
  availableAgents: '可用助手',
19
19
  backToBottom: '跳转至当前',
20
20
  chatList: {
21
+ expandMessage: '展开消息',
21
22
  longMessageDetail: '查看详情',
22
23
  },
23
24
  clearCurrentMessages: '清空当前会话消息',
@@ -188,9 +189,11 @@ export default {
188
189
  },
189
190
 
190
191
  messageAction: {
192
+ collapse: '收起消息',
191
193
  continueGeneration: '继续生成',
192
194
  delAndRegenerate: '删除并重新生成',
193
195
  deleteDisabledByThreads: '存在子话题,不能删除',
196
+ expand: '展开消息',
194
197
  regenerate: '重新生成',
195
198
  },
196
199
 
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable sort-keys-fix/sort-keys-fix, typescript-sort-keys/interface */
2
2
  // Disable the auto sort key eslint rule to make the code more logic and readable
3
- import { DEFAULT_AGENT_CHAT_CONFIG, INBOX_SESSION_ID } from '@lobechat/const';
3
+ import { DEFAULT_AGENT_CHAT_CONFIG, INBOX_SESSION_ID, LOADING_FLAT } from '@lobechat/const';
4
4
  import {
5
5
  ChatImageItem,
6
6
  ChatVideoItem,
@@ -124,6 +124,14 @@ export const conversationLifecycle: StateCreator<
124
124
  imageList: tempImages.length > 0 ? tempImages : undefined,
125
125
  videoList: tempVideos.length > 0 ? tempVideos : undefined,
126
126
  });
127
+ get().optimisticCreateTmpMessage({
128
+ content: LOADING_FLAT,
129
+ role: 'assistant',
130
+ sessionId: activeId,
131
+ // if there is activeTopicId,then add topicId to message
132
+ topicId: activeTopicId,
133
+ threadId: activeThreadId,
134
+ });
127
135
  get().internal_toggleMessageLoading(true, tempId);
128
136
 
129
137
  const operationKey = messageMapKey(activeId, activeTopicId);
@@ -43,6 +43,10 @@ export interface MessagePublicApiAction {
43
43
  updateMessageInput: (message: string) => void;
44
44
  modifyMessageContent: (id: string, content: string) => Promise<void>;
45
45
  toggleMessageEditing: (id: string, editing: boolean) => void;
46
+ /**
47
+ * Toggle message collapsed state
48
+ */
49
+ toggleMessageCollapsed: (id: string, collapsed?: boolean) => Promise<void>;
46
50
 
47
51
  // ===== Others ===== //
48
52
  copyMessage: (id: string, content: string) => Promise<void>;
@@ -241,4 +245,17 @@ export const messagePublicApi: StateCreator<
241
245
 
242
246
  await get().optimisticUpdateMessageContent(id, content);
243
247
  },
248
+
249
+ toggleMessageCollapsed: async (id, collapsed) => {
250
+ const message = displayMessageSelectors.getDisplayMessageById(id)(get());
251
+ if (!message) return;
252
+
253
+ // 如果没有传入 collapsed,则取反当前状态
254
+ const nextCollapsed = collapsed ?? !message.metadata?.collapsed;
255
+
256
+ // 直接调用现有的 optimisticUpdateMessageMetadata
257
+ await get().optimisticUpdateMessageMetadata(id, {
258
+ collapsed: nextCollapsed,
259
+ });
260
+ },
244
261
  });
@@ -85,7 +85,7 @@ const activeDisplayMessages = (s: ChatStoreState): UIChatMessage[] => {
85
85
  /**
86
86
  * Get display message by ID (searches in messagesMap including assistantGroup children)
87
87
  */
88
- const getDisplayMessageById = (id: string) => (s: ChatStoreState) =>
88
+ export const getDisplayMessageById = (id: string) => (s: ChatStoreState) =>
89
89
  chatHelpers.getMessageById(activeDisplayMessages(s), id);
90
90
 
91
91
  const lastDisplayMessageId = (s: ChatStoreState) => {
@@ -1,6 +1,7 @@
1
1
  import type { ChatStoreState } from '../../../initialState';
2
2
  import { mainDisplayChatIDs } from './chat';
3
3
  import { getDbMessageByToolCallId } from './dbMessage';
4
+ import { getDisplayMessageById } from './displayMessage';
4
5
 
5
6
  const isMessageEditing = (id: string) => (s: ChatStoreState) => s.messageEditingIds.includes(id);
6
7
  const isMessageLoading = (id: string) => (s: ChatStoreState) => s.messageLoadingIds.includes(id);
@@ -13,6 +14,11 @@ const isMessageInRAGFlow = (id: string) => (s: ChatStoreState) =>
13
14
  const isMessageInChatReasoning = (id: string) => (s: ChatStoreState) =>
14
15
  s.reasoningLoadingIds.includes(id);
15
16
 
17
+ const isMessageCollapsed = (id: string) => (s: ChatStoreState) => {
18
+ const message = getDisplayMessageById(id)(s);
19
+ return message?.metadata?.collapsed ?? false;
20
+ };
21
+
16
22
  const isPluginApiInvoking = (id: string) => (s: ChatStoreState) =>
17
23
  s.pluginApiLoadingIds.includes(id);
18
24
 
@@ -71,6 +77,7 @@ export const messageStateSelectors = {
71
77
  isHasMessageLoading,
72
78
  isInRAGFlow,
73
79
  isInToolsCalling,
80
+ isMessageCollapsed,
74
81
  isMessageContinuing,
75
82
  isMessageEditing,
76
83
  isMessageGenerating,
@@ -1,5 +1,3 @@
1
- import { chainLangDetect } from '@lobechat/prompts';
2
- import { chainTranslate } from '@lobechat/prompts';
3
1
  import { act, renderHook } from '@testing-library/react';
4
2
  import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
3
 
@@ -9,7 +7,7 @@ import { messageMapKey } from '@/store/chat/utils/messageMapKey';
9
7
 
10
8
  import { useChatStore } from '../../store';
11
9
 
12
- // Mock messageService chatService
10
+ // Mock messageService and chatService
13
11
  vi.mock('@/services/message', () => ({
14
12
  messageService: {
15
13
  updateMessageTTS: vi.fn(),
@@ -24,27 +22,20 @@ vi.mock('@/services/chat', () => ({
24
22
  },
25
23
  }));
26
24
 
27
- vi.mock('@/chains/langDetect', () => ({
28
- chainLangDetect: vi.fn(),
29
- }));
30
-
31
- vi.mock('@/chains/translate', () => ({
32
- chainTranslate: vi.fn(),
25
+ vi.mock('@/store/user', () => ({
26
+ useUserStore: {
27
+ getState: vi.fn(() => ({})),
28
+ },
33
29
  }));
34
30
 
35
- // Mock supportLocales
36
- vi.mock('@/locales/options', () => ({
37
- supportLocales: ['en-US', 'zh-CN'],
31
+ vi.mock('@/store/user/selectors', () => ({
32
+ systemAgentSelectors: {
33
+ translation: vi.fn(() => ({})),
34
+ },
38
35
  }));
39
36
 
40
37
  beforeEach(() => {
41
38
  vi.clearAllMocks();
42
- useChatStore.setState(
43
- {
44
- // ... 初始状态
45
- },
46
- false,
47
- );
48
39
  });
49
40
 
50
41
  afterEach(() => {
@@ -53,26 +44,26 @@ afterEach(() => {
53
44
 
54
45
  describe('ChatEnhanceAction', () => {
55
46
  describe('translateMessage', () => {
56
- it('should translate a message to the target language and refresh messages', async () => {
57
- const { result } = renderHook(() => useChatStore());
47
+ it('should translate a message to the target language', async () => {
58
48
  const messageId = 'message-id';
59
49
  const targetLang = 'zh-CN';
60
50
  const messageContent = 'Hello World';
61
51
  const detectedLang = 'en-US';
52
+ const translatedText = '你好世界';
62
53
 
54
+ // Setup initial state
63
55
  act(() => {
64
56
  useChatStore.setState({
65
57
  activeId: 'session',
66
- messagesMap: {
58
+ dbMessagesMap: {
67
59
  [messageMapKey('session')]: [
68
60
  {
69
61
  id: messageId,
70
62
  content: messageContent,
71
63
  createdAt: Date.now(),
72
64
  updatedAt: Date.now(),
73
- role: 'user',
74
- sessionId: 'test',
75
- topicId: 'test',
65
+ role: 'assistant',
66
+ sessionId: 'session',
76
67
  meta: {},
77
68
  },
78
69
  ],
@@ -80,21 +71,24 @@ describe('ChatEnhanceAction', () => {
80
71
  });
81
72
  });
82
73
 
83
- (chatService.fetchPresetTaskResult as Mock).mockImplementation(({ params }) => {
84
- if (params === chainLangDetect(messageContent)) {
85
- return Promise.resolve(detectedLang);
86
- }
87
- if (params === chainTranslate(messageContent, targetLang)) {
88
- return Promise.resolve('Hola Mundo');
89
- }
90
- return Promise.resolve(undefined);
74
+ // First call for language detection
75
+ (chatService.fetchPresetTaskResult as Mock).mockImplementationOnce(async ({ onFinish }) => {
76
+ if (onFinish) await onFinish(detectedLang);
91
77
  });
92
78
 
79
+ // Second call for translation
80
+ (chatService.fetchPresetTaskResult as Mock).mockImplementationOnce(async ({ onFinish }) => {
81
+ if (onFinish) await onFinish(translatedText);
82
+ });
83
+
84
+ const { result } = renderHook(() => useChatStore());
85
+
93
86
  await act(async () => {
94
87
  await result.current.translateMessage(messageId, targetLang);
95
88
  });
96
89
 
97
90
  expect(messageService.updateMessageTranslate).toHaveBeenCalled();
91
+ expect(chatService.fetchPresetTaskResult).toHaveBeenCalledTimes(2);
98
92
  });
99
93
  });
100
94
 
@@ -1,16 +1,16 @@
1
1
  import { chainLangDetect, chainTranslate } from '@lobechat/prompts';
2
2
  import { ChatTranslate, TraceNameMap, TracePayload } from '@lobechat/types';
3
+ import { merge } from '@lobechat/utils';
3
4
  import { produce } from 'immer';
4
5
  import { StateCreator } from 'zustand/vanilla';
5
6
 
6
7
  import { supportLocales } from '@/locales/resources';
7
8
  import { chatService } from '@/services/chat';
8
9
  import { messageService } from '@/services/message';
9
- import { chatSelectors } from '@/store/chat/selectors';
10
+ import { dbMessageSelectors } from '@/store/chat/selectors';
10
11
  import { ChatStore } from '@/store/chat/store';
11
12
  import { useUserStore } from '@/store/user';
12
13
  import { systemAgentSelectors } from '@/store/user/selectors';
13
- import { merge } from '@/utils/merge';
14
14
  import { setNamespace } from '@/utils/storeDebug';
15
15
 
16
16
  const n = setNamespace('enhance');
@@ -43,7 +43,7 @@ export const chatTranslate: StateCreator<
43
43
  translateMessage: async (id, targetLang) => {
44
44
  const { internal_toggleChatLoading, updateMessageTranslate, internal_dispatchMessage } = get();
45
45
 
46
- const message = chatSelectors.getMessageById(id)(get());
46
+ const message = dbMessageSelectors.getDbMessageById(id)(get());
47
47
  if (!message) return;
48
48
 
49
49
  // Get current agent for translation