@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.
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/next.config.ts +5 -6
- package/package.json +2 -2
- package/packages/agent-runtime/src/core/__tests__/runtime.test.ts +112 -77
- package/packages/agent-runtime/src/core/runtime.ts +63 -18
- package/packages/agent-runtime/src/types/generalAgent.ts +55 -0
- package/packages/agent-runtime/src/types/index.ts +1 -0
- package/packages/agent-runtime/src/types/instruction.ts +10 -3
- package/packages/const/src/user.ts +0 -1
- package/packages/context-engine/src/processors/GroupMessageFlatten.ts +8 -6
- package/packages/context-engine/src/processors/__tests__/GroupMessageFlatten.test.ts +12 -12
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/assistant-group-branches.json +249 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/index.ts +4 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/multi-assistant-group.json +260 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/active-index-1.json +4 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/assistant-group-branches.json +481 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/conversation.json +5 -1
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/index.ts +4 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/multi-assistant-group.json +407 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/nested.json +18 -2
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/complex-scenario.json +25 -3
- package/packages/conversation-flow/src/__tests__/parse.test.ts +12 -0
- package/packages/conversation-flow/src/index.ts +1 -1
- package/packages/conversation-flow/src/transformation/FlatListBuilder.ts +112 -34
- package/packages/conversation-flow/src/types/flatMessageList.ts +0 -12
- package/packages/conversation-flow/src/{types.ts → types/index.ts} +3 -14
- package/packages/database/src/models/message.ts +18 -19
- package/packages/types/src/aiChat.ts +2 -0
- package/packages/types/src/importer.ts +2 -2
- package/packages/types/src/message/ui/chat.ts +17 -1
- package/packages/types/src/message/ui/extra.ts +2 -2
- package/packages/types/src/message/ui/params.ts +2 -2
- package/packages/types/src/user/preference.ts +0 -4
- package/packages/utils/src/tokenizer/index.ts +3 -11
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/Desktop/MessageFromUrl.tsx +3 -3
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/index.tsx +1 -1
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/V1Mobile/useSend.ts +3 -3
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatInput/useSend.ts +6 -6
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/Content.tsx +5 -3
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/WelcomeChatItem/AgentWelcome/OpeningQuestions.tsx +2 -2
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/WelcomeChatItem/GroupWelcome/GroupUsageSuggest.tsx +2 -2
- package/src/app/[variants]/(main)/labs/page.tsx +0 -9
- package/src/features/ChatInput/ActionBar/STT/browser.tsx +3 -3
- package/src/features/ChatInput/ActionBar/STT/openai.tsx +3 -3
- package/src/features/Conversation/Error/AccessCodeForm.tsx +1 -1
- package/src/features/Conversation/Error/ChatInvalidApiKey.tsx +1 -1
- package/src/features/Conversation/Error/ClerkLogin/index.tsx +1 -1
- package/src/features/Conversation/Error/OAuthForm.tsx +1 -1
- package/src/features/Conversation/Error/index.tsx +0 -5
- package/src/features/Conversation/Messages/Assistant/Actions/index.tsx +13 -10
- package/src/features/Conversation/Messages/Assistant/Extra/index.test.tsx +3 -8
- package/src/features/Conversation/Messages/Assistant/Extra/index.tsx +2 -6
- package/src/features/Conversation/Messages/Assistant/MessageContent.tsx +7 -9
- package/src/features/Conversation/Messages/Assistant/Tool/Inspector/PluginResult.tsx +2 -2
- package/src/features/Conversation/Messages/Assistant/Tool/Inspector/PluginState.tsx +2 -2
- package/src/features/Conversation/Messages/Assistant/Tool/Render/PluginSettings.tsx +4 -1
- package/src/features/Conversation/Messages/Assistant/Tool/Render/index.tsx +2 -3
- package/src/features/Conversation/Messages/Assistant/index.tsx +57 -60
- package/src/features/Conversation/Messages/Default.tsx +1 -0
- package/src/features/Conversation/Messages/Group/Actions/WithContentId.tsx +38 -10
- package/src/features/Conversation/Messages/Group/Actions/index.tsx +1 -1
- package/src/features/Conversation/Messages/Group/ContentBlock.tsx +1 -3
- package/src/features/Conversation/Messages/Group/GroupChildren.tsx +12 -12
- package/src/features/Conversation/Messages/Group/MessageContent.tsx +7 -1
- package/src/features/Conversation/Messages/Group/Tool/Render/PluginSettings.tsx +1 -1
- package/src/features/Conversation/Messages/Group/index.tsx +2 -1
- package/src/features/Conversation/Messages/Supervisor/index.tsx +2 -2
- package/src/features/Conversation/Messages/User/{Actions.tsx → Actions/ActionsBar.tsx} +26 -25
- package/src/features/Conversation/Messages/User/Actions/MessageBranch.tsx +107 -0
- package/src/features/Conversation/Messages/User/Actions/index.tsx +42 -0
- package/src/features/Conversation/Messages/User/index.tsx +43 -44
- package/src/features/Conversation/Messages/index.tsx +3 -3
- package/src/features/Conversation/components/AutoScroll.tsx +3 -3
- package/src/features/Conversation/components/Extras/Usage/UsageDetail/AnimatedNumber.tsx +55 -0
- package/src/features/Conversation/components/Extras/Usage/UsageDetail/index.tsx +5 -2
- package/src/features/Conversation/components/VirtualizedList/index.tsx +29 -20
- package/src/features/Conversation/hooks/useChatListActionsBar.tsx +8 -10
- package/src/features/Portal/Thread/Chat/ChatInput/useSend.ts +3 -3
- package/src/hooks/useHotkeys/chatScope.ts +15 -7
- package/src/server/routers/lambda/__tests__/aiChat.test.ts +1 -1
- package/src/server/routers/lambda/__tests__/integration/message.integration.test.ts +0 -26
- package/src/server/routers/lambda/aiChat.ts +3 -2
- package/src/server/routers/lambda/message.ts +8 -16
- package/src/server/services/message/__tests__/index.test.ts +29 -39
- package/src/server/services/message/index.ts +41 -36
- package/src/services/electron/desktopNotification.ts +6 -6
- package/src/services/electron/file.ts +6 -6
- package/src/services/file/ClientS3/index.ts +8 -8
- package/src/services/message/__tests__/metadata-race-condition.test.ts +157 -0
- package/src/services/message/index.ts +21 -15
- package/src/services/upload.ts +11 -11
- package/src/services/utils/abortableRequest.test.ts +161 -0
- package/src/services/utils/abortableRequest.ts +67 -0
- package/src/store/chat/agents/GeneralChatAgent.ts +137 -0
- package/src/store/chat/agents/createAgentExecutors.ts +395 -0
- package/src/store/chat/helpers.test.ts +0 -99
- package/src/store/chat/helpers.ts +0 -11
- package/src/store/chat/slices/aiChat/actions/__tests__/conversationControl.test.ts +332 -0
- package/src/store/chat/slices/aiChat/actions/__tests__/conversationLifecycle.test.ts +257 -0
- package/src/store/chat/slices/aiChat/actions/__tests__/helpers.ts +11 -2
- package/src/store/chat/slices/aiChat/actions/__tests__/rag.test.ts +6 -6
- package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +391 -0
- package/src/store/chat/slices/aiChat/actions/__tests__/streamingStates.test.ts +179 -0
- package/src/store/chat/slices/aiChat/actions/conversationControl.ts +157 -0
- package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +329 -0
- package/src/store/chat/slices/aiChat/actions/generateAIGroupChat.ts +14 -14
- package/src/store/chat/slices/aiChat/actions/index.ts +12 -6
- package/src/store/chat/slices/aiChat/actions/rag.ts +9 -6
- package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +604 -0
- package/src/store/chat/slices/aiChat/actions/streamingStates.ts +84 -0
- package/src/store/chat/slices/builtinTool/actions/__tests__/localSystem.test.ts +4 -4
- package/src/store/chat/slices/builtinTool/actions/__tests__/search.test.ts +11 -11
- package/src/store/chat/slices/builtinTool/actions/interpreter.ts +8 -8
- package/src/store/chat/slices/builtinTool/actions/localSystem.ts +2 -2
- package/src/store/chat/slices/builtinTool/actions/search.ts +8 -8
- package/src/store/chat/slices/message/action.test.ts +79 -68
- package/src/store/chat/slices/message/actions/index.ts +39 -0
- package/src/store/chat/slices/message/actions/internals.ts +77 -0
- package/src/store/chat/slices/message/actions/optimisticUpdate.ts +260 -0
- package/src/store/chat/slices/message/actions/publicApi.ts +224 -0
- package/src/store/chat/slices/message/actions/query.ts +120 -0
- package/src/store/chat/slices/message/actions/runtimeState.ts +108 -0
- package/src/store/chat/slices/message/initialState.ts +13 -0
- package/src/store/chat/slices/message/reducer.test.ts +48 -370
- package/src/store/chat/slices/message/reducer.ts +17 -81
- package/src/store/chat/slices/message/selectors/chat.test.ts +13 -50
- package/src/store/chat/slices/message/selectors/chat.ts +78 -242
- package/src/store/chat/slices/message/selectors/dbMessage.ts +140 -0
- package/src/store/chat/slices/message/selectors/displayMessage.ts +301 -0
- package/src/store/chat/slices/message/selectors/messageState.ts +5 -2
- package/src/store/chat/slices/plugin/action.test.ts +62 -64
- package/src/store/chat/slices/plugin/action.ts +34 -28
- package/src/store/chat/slices/thread/action.test.ts +28 -31
- package/src/store/chat/slices/thread/action.ts +13 -10
- package/src/store/chat/slices/thread/selectors/index.ts +8 -6
- package/src/store/chat/slices/topic/reducer.ts +11 -3
- package/src/store/chat/store.ts +1 -1
- package/src/store/user/slices/preference/selectors/labPrefer.ts +0 -3
- package/packages/database/src/models/__tests__/message.grouping.test.ts +0 -812
- package/packages/database/src/utils/__tests__/groupMessages.test.ts +0 -1132
- package/packages/database/src/utils/groupMessages.ts +0 -361
- package/packages/utils/src/tokenizer/client.ts +0 -35
- package/packages/utils/src/tokenizer/estimated.ts +0 -4
- package/packages/utils/src/tokenizer/server.ts +0 -11
- package/packages/utils/src/tokenizer/tokenizer.worker.ts +0 -12
- package/src/app/(backend)/webapi/tokenizer/index.test.ts +0 -32
- package/src/app/(backend)/webapi/tokenizer/route.ts +0 -8
- package/src/features/Conversation/Error/InvalidAccessCode.tsx +0 -79
- package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChat.test.ts +0 -975
- package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts +0 -1050
- package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +0 -720
- package/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts +0 -849
- 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
|
-
|
|
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
|
|
package/src/services/upload.ts
CHANGED
|
@@ -68,13 +68,13 @@ class UploadService {
|
|
|
68
68
|
const state = getElectronStoreState();
|
|
69
69
|
const isSyncActive = electronSyncSelectors.isSyncActive(state);
|
|
70
70
|
|
|
71
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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();
|