@lobehub/lobehub 2.0.0-next.35 → 2.0.0-next.36

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 (154) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/changelog/v1.json +9 -0
  3. package/next.config.ts +5 -6
  4. package/package.json +2 -2
  5. package/packages/agent-runtime/src/core/__tests__/runtime.test.ts +112 -77
  6. package/packages/agent-runtime/src/core/runtime.ts +63 -18
  7. package/packages/agent-runtime/src/types/generalAgent.ts +55 -0
  8. package/packages/agent-runtime/src/types/index.ts +1 -0
  9. package/packages/agent-runtime/src/types/instruction.ts +10 -3
  10. package/packages/const/src/user.ts +0 -1
  11. package/packages/context-engine/src/processors/GroupMessageFlatten.ts +8 -6
  12. package/packages/context-engine/src/processors/__tests__/GroupMessageFlatten.test.ts +12 -12
  13. package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/assistant-group-branches.json +249 -0
  14. package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/index.ts +4 -0
  15. package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/multi-assistant-group.json +260 -0
  16. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/active-index-1.json +4 -0
  17. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/assistant-group-branches.json +481 -0
  18. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/conversation.json +5 -1
  19. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/index.ts +4 -0
  20. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/multi-assistant-group.json +407 -0
  21. package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/nested.json +18 -2
  22. package/packages/conversation-flow/src/__tests__/fixtures/outputs/complex-scenario.json +25 -3
  23. package/packages/conversation-flow/src/__tests__/parse.test.ts +12 -0
  24. package/packages/conversation-flow/src/index.ts +1 -1
  25. package/packages/conversation-flow/src/transformation/FlatListBuilder.ts +112 -34
  26. package/packages/conversation-flow/src/types/flatMessageList.ts +0 -12
  27. package/packages/conversation-flow/src/{types.ts → types/index.ts} +3 -14
  28. package/packages/database/src/models/message.ts +18 -19
  29. package/packages/types/src/aiChat.ts +2 -0
  30. package/packages/types/src/importer.ts +2 -2
  31. package/packages/types/src/message/ui/chat.ts +17 -1
  32. package/packages/types/src/message/ui/extra.ts +2 -2
  33. package/packages/types/src/message/ui/params.ts +2 -2
  34. package/packages/types/src/user/preference.ts +0 -4
  35. package/packages/utils/src/tokenizer/index.ts +3 -11
  36. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/Desktop/MessageFromUrl.tsx +3 -3
  37. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/index.tsx +1 -1
  38. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/useSend.ts +3 -3
  39. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/useSend.ts +6 -6
  40. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/Content.tsx +5 -3
  41. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/WelcomeChatItem/AgentWelcome/OpeningQuestions.tsx +2 -2
  42. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/WelcomeChatItem/GroupWelcome/GroupUsageSuggest.tsx +2 -2
  43. package/src/app/[variants]/(main)/labs/page.tsx +0 -9
  44. package/src/features/ChatInput/ActionBar/STT/browser.tsx +3 -3
  45. package/src/features/ChatInput/ActionBar/STT/openai.tsx +3 -3
  46. package/src/features/Conversation/Error/AccessCodeForm.tsx +1 -1
  47. package/src/features/Conversation/Error/ChatInvalidApiKey.tsx +1 -1
  48. package/src/features/Conversation/Error/ClerkLogin/index.tsx +1 -1
  49. package/src/features/Conversation/Error/OAuthForm.tsx +1 -1
  50. package/src/features/Conversation/Error/index.tsx +0 -5
  51. package/src/features/Conversation/Messages/Assistant/Actions/index.tsx +13 -10
  52. package/src/features/Conversation/Messages/Assistant/Extra/index.test.tsx +3 -8
  53. package/src/features/Conversation/Messages/Assistant/Extra/index.tsx +2 -6
  54. package/src/features/Conversation/Messages/Assistant/MessageContent.tsx +7 -9
  55. package/src/features/Conversation/Messages/Assistant/Tool/Inspector/PluginResult.tsx +2 -2
  56. package/src/features/Conversation/Messages/Assistant/Tool/Inspector/PluginState.tsx +2 -2
  57. package/src/features/Conversation/Messages/Assistant/Tool/Render/PluginSettings.tsx +4 -1
  58. package/src/features/Conversation/Messages/Assistant/Tool/Render/index.tsx +2 -3
  59. package/src/features/Conversation/Messages/Assistant/index.tsx +57 -60
  60. package/src/features/Conversation/Messages/Default.tsx +1 -0
  61. package/src/features/Conversation/Messages/Group/Actions/WithContentId.tsx +38 -10
  62. package/src/features/Conversation/Messages/Group/Actions/index.tsx +1 -1
  63. package/src/features/Conversation/Messages/Group/ContentBlock.tsx +1 -3
  64. package/src/features/Conversation/Messages/Group/GroupChildren.tsx +12 -12
  65. package/src/features/Conversation/Messages/Group/MessageContent.tsx +7 -1
  66. package/src/features/Conversation/Messages/Group/Tool/Render/PluginSettings.tsx +1 -1
  67. package/src/features/Conversation/Messages/Group/index.tsx +2 -1
  68. package/src/features/Conversation/Messages/Supervisor/index.tsx +2 -2
  69. package/src/features/Conversation/Messages/User/{Actions.tsx → Actions/ActionsBar.tsx} +26 -25
  70. package/src/features/Conversation/Messages/User/Actions/MessageBranch.tsx +107 -0
  71. package/src/features/Conversation/Messages/User/Actions/index.tsx +42 -0
  72. package/src/features/Conversation/Messages/User/index.tsx +43 -44
  73. package/src/features/Conversation/Messages/index.tsx +3 -3
  74. package/src/features/Conversation/components/AutoScroll.tsx +3 -3
  75. package/src/features/Conversation/components/Extras/Usage/UsageDetail/AnimatedNumber.tsx +55 -0
  76. package/src/features/Conversation/components/Extras/Usage/UsageDetail/index.tsx +5 -2
  77. package/src/features/Conversation/components/VirtualizedList/index.tsx +29 -20
  78. package/src/features/Conversation/hooks/useChatListActionsBar.tsx +8 -10
  79. package/src/features/Portal/Thread/Chat/ChatInput/useSend.ts +3 -3
  80. package/src/hooks/useHotkeys/chatScope.ts +15 -7
  81. package/src/server/routers/lambda/__tests__/aiChat.test.ts +1 -1
  82. package/src/server/routers/lambda/__tests__/integration/message.integration.test.ts +0 -26
  83. package/src/server/routers/lambda/aiChat.ts +3 -2
  84. package/src/server/routers/lambda/message.ts +8 -16
  85. package/src/server/services/message/__tests__/index.test.ts +29 -39
  86. package/src/server/services/message/index.ts +41 -36
  87. package/src/services/electron/desktopNotification.ts +6 -6
  88. package/src/services/electron/file.ts +6 -6
  89. package/src/services/file/ClientS3/index.ts +8 -8
  90. package/src/services/message/__tests__/metadata-race-condition.test.ts +157 -0
  91. package/src/services/message/index.ts +21 -15
  92. package/src/services/upload.ts +11 -11
  93. package/src/services/utils/abortableRequest.test.ts +161 -0
  94. package/src/services/utils/abortableRequest.ts +67 -0
  95. package/src/store/chat/agents/GeneralChatAgent.ts +137 -0
  96. package/src/store/chat/agents/createAgentExecutors.ts +395 -0
  97. package/src/store/chat/helpers.test.ts +0 -99
  98. package/src/store/chat/helpers.ts +0 -11
  99. package/src/store/chat/slices/aiChat/actions/__tests__/conversationControl.test.ts +332 -0
  100. package/src/store/chat/slices/aiChat/actions/__tests__/conversationLifecycle.test.ts +257 -0
  101. package/src/store/chat/slices/aiChat/actions/__tests__/helpers.ts +11 -2
  102. package/src/store/chat/slices/aiChat/actions/__tests__/rag.test.ts +6 -6
  103. package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +391 -0
  104. package/src/store/chat/slices/aiChat/actions/__tests__/streamingStates.test.ts +179 -0
  105. package/src/store/chat/slices/aiChat/actions/conversationControl.ts +157 -0
  106. package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +329 -0
  107. package/src/store/chat/slices/aiChat/actions/generateAIGroupChat.ts +14 -14
  108. package/src/store/chat/slices/aiChat/actions/index.ts +12 -6
  109. package/src/store/chat/slices/aiChat/actions/rag.ts +9 -6
  110. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +604 -0
  111. package/src/store/chat/slices/aiChat/actions/streamingStates.ts +84 -0
  112. package/src/store/chat/slices/builtinTool/actions/__tests__/localSystem.test.ts +4 -4
  113. package/src/store/chat/slices/builtinTool/actions/__tests__/search.test.ts +11 -11
  114. package/src/store/chat/slices/builtinTool/actions/interpreter.ts +8 -8
  115. package/src/store/chat/slices/builtinTool/actions/localSystem.ts +2 -2
  116. package/src/store/chat/slices/builtinTool/actions/search.ts +8 -8
  117. package/src/store/chat/slices/message/action.test.ts +79 -68
  118. package/src/store/chat/slices/message/actions/index.ts +39 -0
  119. package/src/store/chat/slices/message/actions/internals.ts +77 -0
  120. package/src/store/chat/slices/message/actions/optimisticUpdate.ts +260 -0
  121. package/src/store/chat/slices/message/actions/publicApi.ts +224 -0
  122. package/src/store/chat/slices/message/actions/query.ts +120 -0
  123. package/src/store/chat/slices/message/actions/runtimeState.ts +108 -0
  124. package/src/store/chat/slices/message/initialState.ts +13 -0
  125. package/src/store/chat/slices/message/reducer.test.ts +48 -370
  126. package/src/store/chat/slices/message/reducer.ts +17 -81
  127. package/src/store/chat/slices/message/selectors/chat.test.ts +13 -50
  128. package/src/store/chat/slices/message/selectors/chat.ts +78 -242
  129. package/src/store/chat/slices/message/selectors/dbMessage.ts +140 -0
  130. package/src/store/chat/slices/message/selectors/displayMessage.ts +301 -0
  131. package/src/store/chat/slices/message/selectors/messageState.ts +5 -2
  132. package/src/store/chat/slices/plugin/action.test.ts +62 -64
  133. package/src/store/chat/slices/plugin/action.ts +34 -28
  134. package/src/store/chat/slices/thread/action.test.ts +28 -31
  135. package/src/store/chat/slices/thread/action.ts +13 -10
  136. package/src/store/chat/slices/thread/selectors/index.ts +8 -6
  137. package/src/store/chat/slices/topic/reducer.ts +11 -3
  138. package/src/store/chat/store.ts +1 -1
  139. package/src/store/user/slices/preference/selectors/labPrefer.ts +0 -3
  140. package/packages/database/src/models/__tests__/message.grouping.test.ts +0 -812
  141. package/packages/database/src/utils/__tests__/groupMessages.test.ts +0 -1132
  142. package/packages/database/src/utils/groupMessages.ts +0 -361
  143. package/packages/utils/src/tokenizer/client.ts +0 -35
  144. package/packages/utils/src/tokenizer/estimated.ts +0 -4
  145. package/packages/utils/src/tokenizer/server.ts +0 -11
  146. package/packages/utils/src/tokenizer/tokenizer.worker.ts +0 -12
  147. package/src/app/(backend)/webapi/tokenizer/index.test.ts +0 -32
  148. package/src/app/(backend)/webapi/tokenizer/route.ts +0 -8
  149. package/src/features/Conversation/Error/InvalidAccessCode.tsx +0 -79
  150. package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChat.test.ts +0 -975
  151. package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts +0 -1050
  152. package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +0 -720
  153. package/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts +0 -849
  154. package/src/store/chat/slices/message/action.ts +0 -629
@@ -0,0 +1,157 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { lambdaClient } from '@/libs/trpc/client';
4
+
5
+ import { MessageService } from '../index';
6
+
7
+ vi.mock('@/libs/trpc/client', () => ({
8
+ lambdaClient: {
9
+ message: {
10
+ updateMetadata: {
11
+ mutate: vi.fn(),
12
+ },
13
+ },
14
+ },
15
+ }));
16
+
17
+ describe('MessageService - Race Condition Control', () => {
18
+ let messageService: MessageService;
19
+
20
+ beforeEach(() => {
21
+ vi.clearAllMocks();
22
+ messageService = new MessageService();
23
+ });
24
+
25
+ describe('updateMessageMetadata race condition', () => {
26
+ it('should cancel previous request when new update is triggered for same message', async () => {
27
+ const messageId = 'test-message-id';
28
+ let firstRequestAborted = false;
29
+ let secondRequestCompleted = false;
30
+
31
+ // Mock first request (slow)
32
+ vi.mocked(lambdaClient.message.updateMetadata.mutate).mockImplementationOnce(
33
+ (_params, options) =>
34
+ new Promise((resolve, reject) => {
35
+ const signal = options?.signal;
36
+ if (signal) {
37
+ signal.addEventListener('abort', () => {
38
+ firstRequestAborted = true;
39
+ reject(new Error('Aborted'));
40
+ });
41
+ }
42
+ // Simulate slow request
43
+ setTimeout(() => resolve({ success: true, messages: [] }), 200);
44
+ }),
45
+ );
46
+
47
+ // Mock second request (fast)
48
+ vi.mocked(lambdaClient.message.updateMetadata.mutate).mockImplementationOnce(
49
+ async (_params, _options) => {
50
+ secondRequestCompleted = true;
51
+ return { success: true, messages: [] };
52
+ },
53
+ );
54
+
55
+ // Start first update
56
+ const firstPromise = messageService.updateMessageMetadata(messageId, { compare: true });
57
+
58
+ // Wait a bit then start second update
59
+ await new Promise((resolve) => setTimeout(resolve, 10));
60
+ const secondPromise = messageService.updateMessageMetadata(messageId, { compare: false });
61
+
62
+ // First should be aborted
63
+ await expect(firstPromise).rejects.toThrow('Aborted');
64
+ expect(firstRequestAborted).toBe(true);
65
+
66
+ // Second should complete successfully
67
+ await expect(secondPromise).resolves.toEqual({ success: true, messages: [] });
68
+ expect(secondRequestCompleted).toBe(true);
69
+ });
70
+
71
+ it('should allow concurrent updates for different messages', async () => {
72
+ const message1Id = 'message-1';
73
+ const message2Id = 'message-2';
74
+
75
+ vi.mocked(lambdaClient.message.updateMetadata.mutate).mockResolvedValue({
76
+ success: true,
77
+ messages: [],
78
+ });
79
+
80
+ const [result1, result2] = await Promise.all([
81
+ messageService.updateMessageMetadata(message1Id, { cost: 0.001 }),
82
+ messageService.updateMessageMetadata(message2Id, { cost: 0.002 }),
83
+ ]);
84
+
85
+ expect(result1).toEqual({ success: true, messages: [] });
86
+ expect(result2).toEqual({ success: true, messages: [] });
87
+ expect(lambdaClient.message.updateMetadata.mutate).toHaveBeenCalledTimes(2);
88
+ });
89
+
90
+ it('should handle rapid successive updates correctly', async () => {
91
+ const messageId = 'test-message-id';
92
+ let completedUpdates = 0;
93
+ const abortedUpdates: number[] = [];
94
+
95
+ // All but the last request should be aborted
96
+ let callIndex = 0;
97
+ vi.mocked(lambdaClient.message.updateMetadata.mutate).mockImplementation(
98
+ (_params, options) => {
99
+ const currentIndex = callIndex++;
100
+ return new Promise((resolve, reject) => {
101
+ const signal = options?.signal;
102
+ let isAborted = false;
103
+
104
+ if (signal) {
105
+ signal.addEventListener('abort', () => {
106
+ isAborted = true;
107
+ abortedUpdates.push(currentIndex);
108
+ reject(new Error('Aborted'));
109
+ });
110
+ }
111
+
112
+ setTimeout(() => {
113
+ if (!isAborted) {
114
+ completedUpdates++;
115
+ resolve({ success: true, messages: [] });
116
+ }
117
+ }, 50);
118
+ });
119
+ },
120
+ );
121
+
122
+ // Trigger 5 rapid updates sequentially with catch to prevent unhandled rejections
123
+ const promise1 = messageService
124
+ .updateMessageMetadata(messageId, { cost: 0.001 })
125
+ .catch((e) => e);
126
+ await new Promise((resolve) => setTimeout(resolve, 5));
127
+ const promise2 = messageService
128
+ .updateMessageMetadata(messageId, { cost: 0.002 })
129
+ .catch((e) => e);
130
+ await new Promise((resolve) => setTimeout(resolve, 5));
131
+ const promise3 = messageService.updateMessageMetadata(messageId, { tps: 10 }).catch((e) => e);
132
+ await new Promise((resolve) => setTimeout(resolve, 5));
133
+ const promise4 = messageService.updateMessageMetadata(messageId, { tps: 20 }).catch((e) => e);
134
+ await new Promise((resolve) => setTimeout(resolve, 5));
135
+ const promise5 = messageService
136
+ .updateMessageMetadata(messageId, { compare: true })
137
+ .catch((e) => e);
138
+
139
+ // Wait for all to settle
140
+ const results = await Promise.all([promise1, promise2, promise3, promise4, promise5]);
141
+
142
+ // First 4 should be errors (aborted), last should succeed
143
+ expect(results[0]).toBeInstanceOf(Error);
144
+ expect(results[1]).toBeInstanceOf(Error);
145
+ expect(results[2]).toBeInstanceOf(Error);
146
+ expect(results[3]).toBeInstanceOf(Error);
147
+ expect(results[4]).toEqual({ success: true, messages: [] });
148
+
149
+ // 4 requests should have been aborted
150
+ expect(abortedUpdates.length).toBe(4);
151
+ expect(abortedUpdates).toEqual([0, 1, 2, 3]);
152
+
153
+ // Only the last request should complete
154
+ expect(completedUpdates).toBe(1);
155
+ });
156
+ });
157
+ });
@@ -5,6 +5,7 @@ import {
5
5
  ChatTranslate,
6
6
  CreateMessageParams,
7
7
  CreateMessageResult,
8
+ MessageMetadata,
8
9
  ModelRankItem,
9
10
  UIChatMessage,
10
11
  UpdateMessageParams,
@@ -15,14 +16,10 @@ import type { HeatmapsProps } from '@lobehub/charts';
15
16
 
16
17
  import { INBOX_SESSION_ID } from '@/const/session';
17
18
  import { lambdaClient } from '@/libs/trpc/client';
18
- import { useUserStore } from '@/store/user';
19
- import { labPreferSelectors } from '@/store/user/selectors';
20
19
 
21
- export class MessageService {
22
- private get useGroup() {
23
- return labPreferSelectors.enableAssistantMessageGroup(useUserStore.getState());
24
- }
20
+ import { abortableRequest } from '../utils/abortableRequest';
25
21
 
22
+ export class MessageService {
26
23
  createMessage = async ({
27
24
  sessionId,
28
25
  ...params
@@ -30,7 +27,6 @@ export class MessageService {
30
27
  return lambdaClient.message.createMessage.mutate({
31
28
  ...params,
32
29
  sessionId: sessionId ? this.toDbSessionId(sessionId) : undefined,
33
- useGroup: this.useGroup,
34
30
  });
35
31
  };
36
32
 
@@ -43,7 +39,6 @@ export class MessageService {
43
39
  groupId,
44
40
  sessionId: this.toDbSessionId(sessionId),
45
41
  topicId,
46
- useGroup: this.useGroup,
47
42
  });
48
43
 
49
44
  return data as unknown as UIChatMessage[];
@@ -53,7 +48,6 @@ export class MessageService {
53
48
  const data = await lambdaClient.message.getMessages.query({
54
49
  groupId,
55
50
  topicId,
56
- useGroup: this.useGroup,
57
51
  });
58
52
  return data as unknown as UIChatMessage[];
59
53
  };
@@ -100,7 +94,6 @@ export class MessageService {
100
94
  id,
101
95
  sessionId: options?.sessionId,
102
96
  topicId: options?.topicId,
103
- useGroup: this.useGroup,
104
97
  value,
105
98
  });
106
99
  };
@@ -113,6 +106,24 @@ export class MessageService {
113
106
  return lambdaClient.message.updateTTS.mutate({ id, value: tts });
114
107
  };
115
108
 
109
+ updateMessageMetadata = async (
110
+ id: string,
111
+ value: Partial<MessageMetadata>,
112
+ options?: { sessionId?: string | null; topicId?: string | null },
113
+ ): Promise<UpdateMessageResult> => {
114
+ return abortableRequest.execute(`message-metadata-${id}`, (signal) =>
115
+ lambdaClient.message.updateMetadata.mutate(
116
+ {
117
+ id,
118
+ sessionId: options?.sessionId,
119
+ topicId: options?.topicId,
120
+ value,
121
+ },
122
+ { signal },
123
+ ),
124
+ );
125
+ };
126
+
116
127
  updateMessagePluginState = async (
117
128
  id: string,
118
129
  value: Record<string, any>,
@@ -122,7 +133,6 @@ export class MessageService {
122
133
  id,
123
134
  sessionId: options?.sessionId,
124
135
  topicId: options?.topicId,
125
- useGroup: this.useGroup,
126
136
  value,
127
137
  });
128
138
  };
@@ -136,7 +146,6 @@ export class MessageService {
136
146
  id,
137
147
  sessionId: options?.sessionId,
138
148
  topicId: options?.topicId,
139
- useGroup: this.useGroup,
140
149
  value: error as any,
141
150
  });
142
151
  };
@@ -150,7 +159,6 @@ export class MessageService {
150
159
  id,
151
160
  sessionId: options?.sessionId,
152
161
  topicId: options?.topicId,
153
- useGroup: this.useGroup,
154
162
  value: data,
155
163
  });
156
164
  };
@@ -163,7 +171,6 @@ export class MessageService {
163
171
  id,
164
172
  sessionId: options?.sessionId,
165
173
  topicId: options?.topicId,
166
- useGroup: this.useGroup,
167
174
  });
168
175
  };
169
176
 
@@ -175,7 +182,6 @@ export class MessageService {
175
182
  ids,
176
183
  sessionId: options?.sessionId,
177
184
  topicId: options?.topicId,
178
- useGroup: this.useGroup,
179
185
  });
180
186
  };
181
187
 
@@ -68,13 +68,13 @@ class UploadService {
68
68
  const state = getElectronStoreState();
69
69
  const isSyncActive = electronSyncSelectors.isSyncActive(state);
70
70
 
71
- // 桌面端上传逻辑(并且没开启 sync 同步)
71
+ // Desktop upload logic (when sync is not enabled)
72
72
  if (isDesktop && !isSyncActive) {
73
73
  const data = await this.uploadToDesktopS3(file, { directory, pathname });
74
74
  return { data, success: true };
75
75
  }
76
76
 
77
- // 服务端上传逻辑
77
+ // Server-side upload logic
78
78
  if (isServerMode) {
79
79
  // if is server mode, upload to server s3,
80
80
 
@@ -83,7 +83,7 @@ class UploadService {
83
83
  }
84
84
 
85
85
  // upload to client s3
86
- // 客户端上传逻辑
86
+ // Client-side upload logic
87
87
  if (!skipCheckFileType && !file.type.startsWith('image') && !file.type.startsWith('video')) {
88
88
  onNotSupported?.();
89
89
  return { data: undefined as unknown as FileMetadata, success: false };
@@ -103,18 +103,18 @@ class UploadService {
103
103
  base64Data: string,
104
104
  options: UploadFileToS3Options = {},
105
105
  ): Promise<UploadBase64ToS3Result> => {
106
- // 解析 base64 数据
106
+ // Parse base64 data
107
107
  const { base64, mimeType, type } = parseDataUri(base64Data);
108
108
 
109
109
  if (!base64 || !mimeType || type !== 'base64') {
110
110
  throw new Error('Invalid base64 data for image');
111
111
  }
112
112
 
113
- // base64 转换为 Blob
113
+ // Convert base64 to Blob
114
114
  const byteCharacters = atob(base64);
115
115
  const byteArrays = [];
116
116
 
117
- // 分块处理以避免内存问题
117
+ // Process in chunks to avoid memory issues
118
118
  for (let offset = 0; offset < byteCharacters.length; offset += 1024) {
119
119
  const slice = byteCharacters.slice(offset, offset + 1024);
120
120
 
@@ -129,14 +129,14 @@ class UploadService {
129
129
 
130
130
  const blob = new Blob(byteArrays, { type: mimeType });
131
131
 
132
- // 确定文件扩展名
132
+ // Determine file extension
133
133
  const fileExtension = mimeType.split('/')[1] || 'png';
134
134
  const fileName = `${options.filename || `image_${dayjs().format('YYYY-MM-DD-hh-mm-ss')}`}.${fileExtension}`;
135
135
 
136
- // 创建文件对象
136
+ // Create file object
137
137
  const file = new File([blob], fileName, { type: mimeType });
138
138
 
139
- // 使用统一的上传方法
139
+ // Use unified upload method
140
140
  const { data: metadata } = await this.uploadFileToS3(file, options);
141
141
  const hash = sha256(await file.arrayBuffer());
142
142
 
@@ -221,7 +221,7 @@ class UploadService {
221
221
  const fileArrayBuffer = await file.arrayBuffer();
222
222
  const hash = sha256(fileArrayBuffer);
223
223
 
224
- // 生成文件路径元数据
224
+ // Generate file path metadata
225
225
  const { pathname } = generateFilePathMetadata(file.name, options);
226
226
 
227
227
  const { desktopFileAPI } = await import('@/services/electron/file');
@@ -261,7 +261,7 @@ class UploadService {
261
261
  preSignUrl: string;
262
262
  }
263
263
  > => {
264
- // 生成文件路径元数据
264
+ // Generate file path metadata
265
265
  const { date, dirname, filename, pathname } = generateFilePathMetadata(file.name, options);
266
266
 
267
267
  const preSignUrl = await lambdaClient.upload.createS3PreSignedUrl.mutate({ pathname });
@@ -0,0 +1,161 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { abortableRequest } from './abortableRequest';
4
+
5
+ describe('AbortableRequestManager', () => {
6
+ beforeEach(() => {
7
+ abortableRequest.cancelAll();
8
+ });
9
+
10
+ describe('execute', () => {
11
+ it('should execute request successfully', async () => {
12
+ const mockFetcher = vi.fn(async (signal: AbortSignal) => {
13
+ return 'result';
14
+ });
15
+
16
+ const result = await abortableRequest.execute('test-key', mockFetcher);
17
+
18
+ expect(result).toBe('result');
19
+ expect(mockFetcher).toHaveBeenCalledTimes(1);
20
+ expect(mockFetcher).toHaveBeenCalledWith(expect.any(AbortSignal));
21
+ });
22
+
23
+ it('should cancel previous request when new request with same key is triggered', async () => {
24
+ let firstRequestAborted = false;
25
+ let secondRequestAborted = false;
26
+
27
+ const firstFetcher = vi.fn(
28
+ async (signal: AbortSignal) =>
29
+ new Promise((resolve, reject) => {
30
+ signal.addEventListener('abort', () => {
31
+ firstRequestAborted = true;
32
+ reject(new Error('Aborted'));
33
+ });
34
+ setTimeout(() => resolve('first'), 100);
35
+ }),
36
+ );
37
+
38
+ const secondFetcher = vi.fn(
39
+ async (signal: AbortSignal) =>
40
+ new Promise((resolve, reject) => {
41
+ signal.addEventListener('abort', () => {
42
+ secondRequestAborted = true;
43
+ reject(new Error('Aborted'));
44
+ });
45
+ setTimeout(() => resolve('second'), 100);
46
+ }),
47
+ );
48
+
49
+ // Start first request
50
+ const firstPromise = abortableRequest.execute('same-key', firstFetcher);
51
+
52
+ // Start second request with same key (should cancel first)
53
+ await new Promise((resolve) => setTimeout(resolve, 10));
54
+ const secondPromise = abortableRequest.execute('same-key', secondFetcher);
55
+
56
+ // First should be aborted
57
+ await expect(firstPromise).rejects.toThrow('Aborted');
58
+ expect(firstRequestAborted).toBe(true);
59
+
60
+ // Second should succeed
61
+ const result = await secondPromise;
62
+ expect(result).toBe('second');
63
+ expect(secondRequestAborted).toBe(false);
64
+ });
65
+
66
+ it('should allow concurrent requests with different keys', async () => {
67
+ const fetcher1 = vi.fn(async () => 'result1');
68
+ const fetcher2 = vi.fn(async () => 'result2');
69
+
70
+ const [result1, result2] = await Promise.all([
71
+ abortableRequest.execute('key1', fetcher1),
72
+ abortableRequest.execute('key2', fetcher2),
73
+ ]);
74
+
75
+ expect(result1).toBe('result1');
76
+ expect(result2).toBe('result2');
77
+ expect(fetcher1).toHaveBeenCalledTimes(1);
78
+ expect(fetcher2).toHaveBeenCalledTimes(1);
79
+ });
80
+
81
+ it('should clean up controller after request completes', async () => {
82
+ const fetcher = vi.fn(async () => 'result');
83
+
84
+ await abortableRequest.execute('cleanup-test', fetcher);
85
+
86
+ // Manually check that controller is cleaned up by starting a new request
87
+ // and verifying it doesn't abort anything (since map should be empty)
88
+ let aborted = false;
89
+ const fetcher2 = vi.fn(
90
+ async (signal: AbortSignal) =>
91
+ new Promise((resolve) => {
92
+ signal.addEventListener('abort', () => {
93
+ aborted = true;
94
+ });
95
+ setTimeout(() => resolve('result2'), 50);
96
+ }),
97
+ );
98
+
99
+ await abortableRequest.execute('cleanup-test', fetcher2);
100
+ expect(aborted).toBe(false);
101
+ });
102
+ });
103
+
104
+ describe('cancel', () => {
105
+ it('should cancel specific request by key', async () => {
106
+ let aborted = false;
107
+ const fetcher = vi.fn(
108
+ async (signal: AbortSignal) =>
109
+ new Promise((resolve, reject) => {
110
+ signal.addEventListener('abort', () => {
111
+ aborted = true;
112
+ reject(new Error('Cancelled'));
113
+ });
114
+ setTimeout(() => resolve('result'), 100);
115
+ }),
116
+ );
117
+
118
+ const promise = abortableRequest.execute('cancel-key', fetcher);
119
+
120
+ await new Promise((resolve) => setTimeout(resolve, 10));
121
+ abortableRequest.cancel('cancel-key');
122
+
123
+ await expect(promise).rejects.toThrow('Cancelled');
124
+ expect(aborted).toBe(true);
125
+ });
126
+
127
+ it('should do nothing when canceling non-existent key', () => {
128
+ expect(() => abortableRequest.cancel('non-existent')).not.toThrow();
129
+ });
130
+ });
131
+
132
+ describe('cancelAll', () => {
133
+ it('should cancel all pending requests', async () => {
134
+ const results = { req1: false, req2: false, req3: false };
135
+
136
+ const createFetcher = (key: keyof typeof results) =>
137
+ vi.fn(
138
+ async (signal: AbortSignal) =>
139
+ new Promise((resolve, reject) => {
140
+ signal.addEventListener('abort', () => {
141
+ results[key] = true;
142
+ reject(new Error('Cancelled'));
143
+ });
144
+ setTimeout(() => resolve(`result-${key}`), 100);
145
+ }),
146
+ );
147
+
148
+ const promise1 = abortableRequest.execute('key1', createFetcher('req1'));
149
+ const promise2 = abortableRequest.execute('key2', createFetcher('req2'));
150
+ const promise3 = abortableRequest.execute('key3', createFetcher('req3'));
151
+
152
+ await new Promise((resolve) => setTimeout(resolve, 10));
153
+ abortableRequest.cancelAll();
154
+
155
+ await expect(Promise.all([promise1, promise2, promise3])).rejects.toThrow();
156
+ expect(results.req1).toBe(true);
157
+ expect(results.req2).toBe(true);
158
+ expect(results.req3).toBe(true);
159
+ });
160
+ });
161
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Abortable Request Manager
3
+ *
4
+ * Provides race condition control for async requests by canceling previous
5
+ * requests when a new one with the same key is triggered.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * const result = await abortableRequest.execute(
10
+ * 'update-user-profile',
11
+ * (signal) => api.updateProfile(data, { signal })
12
+ * );
13
+ * ```
14
+ */
15
+ class AbortableRequestManager {
16
+ private controllers = new Map<string, AbortController>();
17
+
18
+ /**
19
+ * Execute a request with race condition control
20
+ * @param key - Unique key to identify the request group
21
+ * @param fetcher - Request function that accepts AbortSignal
22
+ * @returns Promise with the request result
23
+ */
24
+ async execute<T>(key: string, fetcher: (signal: AbortSignal) => Promise<T>): Promise<T> {
25
+ // Cancel previous request with same key
26
+ const existingController = this.controllers.get(key);
27
+ if (existingController) {
28
+ existingController.abort('New request triggered');
29
+ }
30
+
31
+ const controller = new AbortController();
32
+ this.controllers.set(key, controller);
33
+
34
+ try {
35
+ return await fetcher(controller.signal);
36
+ } finally {
37
+ // Clean up controller if it's still the active one
38
+ if (this.controllers.get(key) === controller) {
39
+ this.controllers.delete(key);
40
+ }
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Manually cancel a request by key
46
+ * @param key - Request key to cancel
47
+ */
48
+ cancel(key: string): void {
49
+ const controller = this.controllers.get(key);
50
+ if (controller) {
51
+ controller.abort('Manually cancelled');
52
+ this.controllers.delete(key);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Cancel all pending requests
58
+ */
59
+ cancelAll(): void {
60
+ for (const controller of this.controllers.values()) {
61
+ controller.abort('All requests cancelled');
62
+ }
63
+ this.controllers.clear();
64
+ }
65
+ }
66
+
67
+ export const abortableRequest = new AbortableRequestManager();