@lobehub/lobehub 2.0.0-next.35 → 2.0.0-next.37
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 +50 -0
- package/changelog/v1.json +18 -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/__tests__/apiKey.test.ts +444 -0
- 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/libs/trpc/client/lambda.ts +4 -3
- 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
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();
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Agent,
|
|
3
|
+
AgentInstruction,
|
|
4
|
+
AgentRuntimeContext,
|
|
5
|
+
AgentState,
|
|
6
|
+
GeneralAgentCallLLMInstructionPayload,
|
|
7
|
+
GeneralAgentCallLLMResultPayload,
|
|
8
|
+
GeneralAgentCallToolResultPayload,
|
|
9
|
+
GeneralAgentCallToolsBatchInstructionPayload,
|
|
10
|
+
GeneralAgentCallingToolInstructionPayload,
|
|
11
|
+
GeneralAgentConfig,
|
|
12
|
+
} from '@lobechat/agent-runtime';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ChatAgent - The "Brain" of the chat agent
|
|
16
|
+
*
|
|
17
|
+
* This agent implements a simple but powerful decision loop:
|
|
18
|
+
* 1. user_input → call_llm (with optional RAG/Search preprocessing)
|
|
19
|
+
* 2. llm_result → check for tool_calls
|
|
20
|
+
* - If has tool_calls → call_tools_batch (parallel execution)
|
|
21
|
+
* - If no tool_calls → finish
|
|
22
|
+
* 3. tools_batch_result → call_llm (process tool results)
|
|
23
|
+
*
|
|
24
|
+
* Note: RAG and Search workflow preprocessing are handled externally
|
|
25
|
+
* before creating the agent runtime, keeping the agent logic simple.
|
|
26
|
+
*/
|
|
27
|
+
export class GeneralChatAgent implements Agent {
|
|
28
|
+
private config: GeneralAgentConfig;
|
|
29
|
+
|
|
30
|
+
constructor(config: GeneralAgentConfig) {
|
|
31
|
+
this.config = config;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async runner(
|
|
35
|
+
context: AgentRuntimeContext,
|
|
36
|
+
state: AgentState,
|
|
37
|
+
): Promise<AgentInstruction | AgentInstruction[]> {
|
|
38
|
+
switch (context.phase) {
|
|
39
|
+
case 'init':
|
|
40
|
+
case 'user_input': {
|
|
41
|
+
// User input received, call LLM to generate response
|
|
42
|
+
// At this point, messages may have been preprocessed with RAG/Search
|
|
43
|
+
return {
|
|
44
|
+
payload: {
|
|
45
|
+
...(context.payload as any),
|
|
46
|
+
messages: state.messages,
|
|
47
|
+
} as GeneralAgentCallLLMInstructionPayload,
|
|
48
|
+
type: 'call_llm',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
case 'llm_result': {
|
|
53
|
+
// LLM response received, check if it contains tool calls
|
|
54
|
+
const { hasToolsCalling, toolsCalling, parentMessageId } =
|
|
55
|
+
context.payload as GeneralAgentCallLLMResultPayload;
|
|
56
|
+
|
|
57
|
+
if (hasToolsCalling && toolsCalling && toolsCalling.length > 0) {
|
|
58
|
+
// No intervention needed, proceed with tool execution
|
|
59
|
+
// Use batch execution for multiple tool calls to improve performance
|
|
60
|
+
if (toolsCalling.length > 1) {
|
|
61
|
+
return {
|
|
62
|
+
payload: {
|
|
63
|
+
parentMessageId,
|
|
64
|
+
toolsCalling,
|
|
65
|
+
} as GeneralAgentCallToolsBatchInstructionPayload,
|
|
66
|
+
type: 'call_tools_batch',
|
|
67
|
+
};
|
|
68
|
+
} else if (toolsCalling.length === 1) {
|
|
69
|
+
// Single tool executes directly
|
|
70
|
+
return {
|
|
71
|
+
payload: {
|
|
72
|
+
parentMessageId,
|
|
73
|
+
toolCalling: toolsCalling[0],
|
|
74
|
+
} as GeneralAgentCallingToolInstructionPayload,
|
|
75
|
+
type: 'call_tool',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// No tool calls, conversation is complete
|
|
81
|
+
return {
|
|
82
|
+
reason: 'completed',
|
|
83
|
+
reasonDetail: 'LLM response completed without tool calls',
|
|
84
|
+
type: 'finish',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
case 'tool_result': {
|
|
89
|
+
const { parentMessageId } = context.payload as GeneralAgentCallToolResultPayload;
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
payload: {
|
|
93
|
+
messages: state.messages,
|
|
94
|
+
model: this.config.modelRuntimeConfig?.model,
|
|
95
|
+
parentMessageId,
|
|
96
|
+
provider: this.config.modelRuntimeConfig?.provider,
|
|
97
|
+
tools: state.tools,
|
|
98
|
+
} as GeneralAgentCallLLMInstructionPayload,
|
|
99
|
+
type: 'call_llm',
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
case 'tools_batch_result': {
|
|
104
|
+
const { parentMessageId } = context.payload as GeneralAgentCallToolResultPayload;
|
|
105
|
+
return {
|
|
106
|
+
payload: {
|
|
107
|
+
messages: state.messages,
|
|
108
|
+
model: this.config.modelRuntimeConfig?.model,
|
|
109
|
+
parentMessageId,
|
|
110
|
+
provider: this.config.modelRuntimeConfig?.provider,
|
|
111
|
+
tools: state.tools,
|
|
112
|
+
} as GeneralAgentCallLLMInstructionPayload,
|
|
113
|
+
type: 'call_llm',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case 'error': {
|
|
118
|
+
// Error occurred, finish execution
|
|
119
|
+
const { error } = context.payload as { error: any };
|
|
120
|
+
return {
|
|
121
|
+
reason: 'error_recovery',
|
|
122
|
+
reasonDetail: error?.message || 'Unknown error occurred',
|
|
123
|
+
type: 'finish',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
default: {
|
|
128
|
+
// Unknown phase, finish execution
|
|
129
|
+
return {
|
|
130
|
+
reason: 'agent_decision',
|
|
131
|
+
reasonDetail: `Unknown phase: ${context.phase}`,
|
|
132
|
+
type: 'finish',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|