@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
|
@@ -165,18 +165,74 @@ export class FlatListBuilder {
|
|
|
165
165
|
childMessages,
|
|
166
166
|
this.childrenMap,
|
|
167
167
|
);
|
|
168
|
-
const
|
|
168
|
+
const activeBranchIndex = childMessages.indexOf(activeBranchId);
|
|
169
|
+
const userWithBranches = this.createUserMessageWithBranches(
|
|
170
|
+
message,
|
|
171
|
+
childMessages.length,
|
|
172
|
+
activeBranchIndex,
|
|
173
|
+
);
|
|
169
174
|
flatList.push(userWithBranches);
|
|
170
175
|
processedIds.add(message.id);
|
|
171
176
|
|
|
172
|
-
// Continue with active branch
|
|
177
|
+
// Continue with active branch - check if it's an assistantGroup
|
|
173
178
|
const activeBranchMsg = this.messageMap.get(activeBranchId);
|
|
174
179
|
if (activeBranchMsg) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
+
// Check if active branch is assistant with tools (should be assistantGroup)
|
|
181
|
+
if (
|
|
182
|
+
activeBranchMsg.role === 'assistant' &&
|
|
183
|
+
activeBranchMsg.tools &&
|
|
184
|
+
activeBranchMsg.tools.length > 0
|
|
185
|
+
) {
|
|
186
|
+
// Collect the entire assistant group chain
|
|
187
|
+
const assistantChain: Message[] = [];
|
|
188
|
+
const allToolMessages: Message[] = [];
|
|
189
|
+
this.messageCollector.collectAssistantChain(
|
|
190
|
+
activeBranchMsg,
|
|
191
|
+
allMessages,
|
|
192
|
+
assistantChain,
|
|
193
|
+
allToolMessages,
|
|
194
|
+
processedIds,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// Create assistantGroup virtual message
|
|
198
|
+
const groupMessage = this.createAssistantGroupMessage(
|
|
199
|
+
assistantChain[0],
|
|
200
|
+
assistantChain,
|
|
201
|
+
allToolMessages,
|
|
202
|
+
);
|
|
203
|
+
flatList.push(groupMessage);
|
|
204
|
+
|
|
205
|
+
// Mark all as processed
|
|
206
|
+
assistantChain.forEach((m) => processedIds.add(m.id));
|
|
207
|
+
allToolMessages.forEach((m) => processedIds.add(m.id));
|
|
208
|
+
|
|
209
|
+
// Continue after the assistant chain
|
|
210
|
+
const lastAssistant = assistantChain.at(-1);
|
|
211
|
+
const toolIds = new Set(allToolMessages.map((t) => t.id));
|
|
212
|
+
|
|
213
|
+
const lastAssistantNonToolChildren = lastAssistant
|
|
214
|
+
? this.childrenMap.get(lastAssistant.id)?.filter((childId) => !toolIds.has(childId))
|
|
215
|
+
: undefined;
|
|
216
|
+
|
|
217
|
+
if (
|
|
218
|
+
lastAssistantNonToolChildren &&
|
|
219
|
+
lastAssistantNonToolChildren.length > 0 &&
|
|
220
|
+
lastAssistant
|
|
221
|
+
) {
|
|
222
|
+
this.buildFlatListRecursive(lastAssistant.id, flatList, processedIds, allMessages);
|
|
223
|
+
} else {
|
|
224
|
+
for (const toolMsg of allToolMessages) {
|
|
225
|
+
this.buildFlatListRecursive(toolMsg.id, flatList, processedIds, allMessages);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
// Regular message (not assistantGroup)
|
|
230
|
+
flatList.push(activeBranchMsg);
|
|
231
|
+
processedIds.add(activeBranchId);
|
|
232
|
+
|
|
233
|
+
// Continue with active branch's children
|
|
234
|
+
this.buildFlatListRecursive(activeBranchId, flatList, processedIds, allMessages);
|
|
235
|
+
}
|
|
180
236
|
}
|
|
181
237
|
continue;
|
|
182
238
|
}
|
|
@@ -364,58 +420,80 @@ export class FlatListBuilder {
|
|
|
364
420
|
assistant.tools?.map((tool) => {
|
|
365
421
|
const toolMsg = toolMap.get(tool.id);
|
|
366
422
|
if (toolMsg) {
|
|
423
|
+
const result: any = {
|
|
424
|
+
content: toolMsg.content || '',
|
|
425
|
+
id: toolMsg.id,
|
|
426
|
+
};
|
|
427
|
+
if (toolMsg.error) result.error = toolMsg.error;
|
|
428
|
+
if (toolMsg.pluginState) result.state = toolMsg.pluginState;
|
|
429
|
+
|
|
367
430
|
return {
|
|
368
431
|
...tool,
|
|
369
|
-
result
|
|
370
|
-
content: toolMsg.content || '',
|
|
371
|
-
error: toolMsg.error,
|
|
372
|
-
id: toolMsg.id,
|
|
373
|
-
state: toolMsg.pluginState,
|
|
374
|
-
},
|
|
432
|
+
result,
|
|
375
433
|
result_msg_id: toolMsg.id,
|
|
376
434
|
};
|
|
377
435
|
}
|
|
378
436
|
return tool;
|
|
379
437
|
}) || [];
|
|
380
438
|
|
|
381
|
-
|
|
439
|
+
// Prefer top-level usage/performance fields, fall back to metadata
|
|
440
|
+
const { usage: metaUsage, performance: metaPerformance } =
|
|
382
441
|
this.messageTransformer.splitMetadata(assistant.metadata);
|
|
442
|
+
const msgUsage = assistant.usage || metaUsage;
|
|
443
|
+
const msgPerformance = assistant.performance || metaPerformance;
|
|
383
444
|
|
|
384
|
-
|
|
445
|
+
const childBlock: AssistantContentBlock = {
|
|
385
446
|
content: assistant.content || '',
|
|
386
|
-
error: assistant.error,
|
|
387
447
|
id: assistant.id,
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
448
|
+
} as AssistantContentBlock;
|
|
449
|
+
|
|
450
|
+
if (assistant.error) childBlock.error = assistant.error;
|
|
451
|
+
if (assistant.imageList && assistant.imageList.length > 0)
|
|
452
|
+
childBlock.imageList = assistant.imageList;
|
|
453
|
+
if (msgPerformance) childBlock.performance = msgPerformance;
|
|
454
|
+
if (assistant.reasoning) childBlock.reasoning = assistant.reasoning;
|
|
455
|
+
if (toolsWithResults.length > 0) childBlock.tools = toolsWithResults;
|
|
456
|
+
if (msgUsage) childBlock.usage = msgUsage;
|
|
457
|
+
|
|
458
|
+
children.push(childBlock);
|
|
395
459
|
}
|
|
396
460
|
|
|
397
461
|
const aggregated = this.messageTransformer.aggregateMetadata(children);
|
|
398
462
|
|
|
399
|
-
|
|
463
|
+
const result: Message = {
|
|
400
464
|
...firstAssistant,
|
|
401
465
|
children,
|
|
402
466
|
content: '',
|
|
403
|
-
imageList: undefined,
|
|
404
|
-
metadata: undefined,
|
|
405
|
-
performance: aggregated.performance,
|
|
406
|
-
reasoning: undefined,
|
|
407
467
|
role: 'assistantGroup' as any,
|
|
408
|
-
tools: undefined,
|
|
409
|
-
usage: aggregated.usage,
|
|
410
468
|
};
|
|
469
|
+
|
|
470
|
+
// Remove fields that should not be in assistantGroup
|
|
471
|
+
delete result.imageList;
|
|
472
|
+
delete result.metadata;
|
|
473
|
+
delete result.reasoning;
|
|
474
|
+
delete result.tools;
|
|
475
|
+
|
|
476
|
+
// Add aggregated fields if they exist
|
|
477
|
+
if (aggregated.performance) result.performance = aggregated.performance;
|
|
478
|
+
if (aggregated.usage) result.usage = aggregated.usage;
|
|
479
|
+
|
|
480
|
+
return result;
|
|
411
481
|
}
|
|
412
482
|
|
|
413
483
|
/**
|
|
414
484
|
* Create user message with branch metadata
|
|
415
485
|
*/
|
|
416
|
-
private createUserMessageWithBranches(
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
486
|
+
private createUserMessageWithBranches(
|
|
487
|
+
user: Message,
|
|
488
|
+
count: number,
|
|
489
|
+
activeBranchIndex: number,
|
|
490
|
+
): Message {
|
|
491
|
+
return {
|
|
492
|
+
...user,
|
|
493
|
+
branch: {
|
|
494
|
+
activeBranchIndex,
|
|
495
|
+
count,
|
|
496
|
+
},
|
|
497
|
+
} as Message;
|
|
420
498
|
}
|
|
421
499
|
}
|
|
@@ -41,22 +41,10 @@ export type FlatMessageRole =
|
|
|
41
41
|
*/
|
|
42
42
|
export type FlatMessage = UIChatMessage;
|
|
43
43
|
|
|
44
|
-
/**
|
|
45
|
-
* Branch metadata attached to user messages
|
|
46
|
-
*/
|
|
47
|
-
export interface BranchMetadata {
|
|
48
|
-
/** Active branch message ID */
|
|
49
|
-
activeId: string;
|
|
50
|
-
/** All branch message IDs */
|
|
51
|
-
branchIds: string[];
|
|
52
|
-
}
|
|
53
|
-
|
|
54
44
|
/**
|
|
55
45
|
* Virtual message extra fields for flat list
|
|
56
46
|
*/
|
|
57
47
|
export interface FlatMessageExtra {
|
|
58
|
-
/** Branch information for user messages with multiple children */
|
|
59
|
-
branches?: BranchMetadata;
|
|
60
48
|
/** Optional description for groups */
|
|
61
49
|
description?: string;
|
|
62
50
|
/** Group mode for messageGroup and compare virtual messages */
|
|
@@ -16,21 +16,10 @@ export type {
|
|
|
16
16
|
CompareNode,
|
|
17
17
|
ContextNode,
|
|
18
18
|
MessageNode,
|
|
19
|
-
} from './
|
|
19
|
+
} from './contextTree';
|
|
20
20
|
|
|
21
21
|
// Flat Message List Types
|
|
22
|
-
export type {
|
|
23
|
-
BranchMetadata,
|
|
24
|
-
FlatMessage,
|
|
25
|
-
FlatMessageExtra,
|
|
26
|
-
FlatMessageRole,
|
|
27
|
-
} from './types/flatMessageList';
|
|
22
|
+
export type { FlatMessage, FlatMessageExtra, FlatMessageRole } from './flatMessageList';
|
|
28
23
|
|
|
29
24
|
// Shared Types
|
|
30
|
-
export type {
|
|
31
|
-
HelperMaps,
|
|
32
|
-
IdNode,
|
|
33
|
-
Message,
|
|
34
|
-
MessageGroupMetadata,
|
|
35
|
-
ParseResult,
|
|
36
|
-
} from './types/shared';
|
|
25
|
+
export type { HelperMaps, IdNode, Message, MessageGroupMetadata, ParseResult } from './shared';
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { eq } from 'drizzle-orm';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { apiKeys, users } from '../../schemas';
|
|
6
|
+
import { LobeChatDatabase } from '../../type';
|
|
7
|
+
import { ApiKeyModel } from '../apiKey';
|
|
8
|
+
import { getTestDB } from './_util';
|
|
9
|
+
|
|
10
|
+
const serverDB: LobeChatDatabase = await getTestDB();
|
|
11
|
+
|
|
12
|
+
const userId = 'api-key-model-test-user-id';
|
|
13
|
+
const apiKeyModel = new ApiKeyModel(serverDB, userId);
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
await serverDB.delete(users);
|
|
17
|
+
await serverDB.insert(users).values([{ id: userId }, { id: 'user2' }]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await serverDB.delete(users).where(eq(users.id, userId));
|
|
22
|
+
await serverDB.delete(apiKeys).where(eq(apiKeys.userId, userId));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('ApiKeyModel', () => {
|
|
26
|
+
describe('create', () => {
|
|
27
|
+
it('should create a new API key without encryption', async () => {
|
|
28
|
+
const params = {
|
|
29
|
+
enabled: true,
|
|
30
|
+
name: 'Test API Key',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const result = await apiKeyModel.create(params);
|
|
34
|
+
|
|
35
|
+
expect(result.id).toBeDefined();
|
|
36
|
+
expect(result.name).toBe(params.name);
|
|
37
|
+
expect(result.enabled).toBe(params.enabled);
|
|
38
|
+
expect(result.key).toBeDefined();
|
|
39
|
+
expect(result.key).toMatch(/^lb-[\da-z]{16}$/);
|
|
40
|
+
expect(result.userId).toBe(userId);
|
|
41
|
+
|
|
42
|
+
const apiKey = await serverDB.query.apiKeys.findFirst({
|
|
43
|
+
where: eq(apiKeys.id, result.id),
|
|
44
|
+
});
|
|
45
|
+
expect(apiKey).toMatchObject({ ...params, userId });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should create a new API key with encryption', async () => {
|
|
49
|
+
const mockEncryptor = vi.fn().mockResolvedValue('encrypted-key-value');
|
|
50
|
+
const params = {
|
|
51
|
+
enabled: true,
|
|
52
|
+
name: 'Encrypted API Key',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const result = await apiKeyModel.create(params, mockEncryptor);
|
|
56
|
+
|
|
57
|
+
expect(result.id).toBeDefined();
|
|
58
|
+
expect(result.name).toBe(params.name);
|
|
59
|
+
expect(result.key).toBe('encrypted-key-value');
|
|
60
|
+
expect(mockEncryptor).toHaveBeenCalledWith(expect.stringMatching(/^lb-[\da-z]{16}$/));
|
|
61
|
+
|
|
62
|
+
const apiKey = await serverDB.query.apiKeys.findFirst({
|
|
63
|
+
where: eq(apiKeys.id, result.id),
|
|
64
|
+
});
|
|
65
|
+
expect(apiKey?.key).toBe('encrypted-key-value');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should create API key with expiration date', async () => {
|
|
69
|
+
const expiresAt = new Date('2025-12-31');
|
|
70
|
+
const params = {
|
|
71
|
+
enabled: true,
|
|
72
|
+
expiresAt,
|
|
73
|
+
name: 'Expiring Key',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const result = await apiKeyModel.create(params);
|
|
77
|
+
|
|
78
|
+
expect(result.expiresAt).toEqual(expiresAt);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('delete', () => {
|
|
83
|
+
it('should delete an API key by id', async () => {
|
|
84
|
+
const { id } = await apiKeyModel.create({ name: 'Test Key', enabled: true });
|
|
85
|
+
|
|
86
|
+
await apiKeyModel.delete(id);
|
|
87
|
+
|
|
88
|
+
const apiKey = await serverDB.query.apiKeys.findFirst({
|
|
89
|
+
where: eq(apiKeys.id, id),
|
|
90
|
+
});
|
|
91
|
+
expect(apiKey).toBeUndefined();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should only delete API keys for the current user', async () => {
|
|
95
|
+
const { id: key1 } = await apiKeyModel.create({ name: 'User 1 Key', enabled: true });
|
|
96
|
+
|
|
97
|
+
const anotherApiKeyModel = new ApiKeyModel(serverDB, 'user2');
|
|
98
|
+
const { id: key2 } = await anotherApiKeyModel.create({ name: 'User 2 Key', enabled: true });
|
|
99
|
+
|
|
100
|
+
await apiKeyModel.delete(key2);
|
|
101
|
+
|
|
102
|
+
const key2Still = await serverDB.query.apiKeys.findFirst({
|
|
103
|
+
where: eq(apiKeys.id, key2),
|
|
104
|
+
});
|
|
105
|
+
expect(key2Still).toBeDefined();
|
|
106
|
+
|
|
107
|
+
await apiKeyModel.delete(key1);
|
|
108
|
+
|
|
109
|
+
const key1Deleted = await serverDB.query.apiKeys.findFirst({
|
|
110
|
+
where: eq(apiKeys.id, key1),
|
|
111
|
+
});
|
|
112
|
+
expect(key1Deleted).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('deleteAll', () => {
|
|
117
|
+
it('should delete all API keys for the user', async () => {
|
|
118
|
+
await apiKeyModel.create({ name: 'Test Key 1', enabled: true });
|
|
119
|
+
await apiKeyModel.create({ name: 'Test Key 2', enabled: true });
|
|
120
|
+
|
|
121
|
+
await apiKeyModel.deleteAll();
|
|
122
|
+
|
|
123
|
+
const userKeys = await serverDB.query.apiKeys.findMany({
|
|
124
|
+
where: eq(apiKeys.userId, userId),
|
|
125
|
+
});
|
|
126
|
+
expect(userKeys).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should only delete API keys for the user, not others', async () => {
|
|
130
|
+
await apiKeyModel.create({ name: 'Test Key 1', enabled: true });
|
|
131
|
+
await apiKeyModel.create({ name: 'Test Key 2', enabled: true });
|
|
132
|
+
|
|
133
|
+
const anotherApiKeyModel = new ApiKeyModel(serverDB, 'user2');
|
|
134
|
+
await anotherApiKeyModel.create({ name: 'User 2 Key', enabled: true });
|
|
135
|
+
|
|
136
|
+
await apiKeyModel.deleteAll();
|
|
137
|
+
|
|
138
|
+
const userKeys = await serverDB.query.apiKeys.findMany({
|
|
139
|
+
where: eq(apiKeys.userId, userId),
|
|
140
|
+
});
|
|
141
|
+
const total = await serverDB.query.apiKeys.findMany();
|
|
142
|
+
expect(userKeys).toHaveLength(0);
|
|
143
|
+
expect(total).toHaveLength(1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('query', () => {
|
|
148
|
+
it('should query API keys for the user without decryption', async () => {
|
|
149
|
+
await apiKeyModel.create({ name: 'Key 1', enabled: true });
|
|
150
|
+
await apiKeyModel.create({ name: 'Key 2', enabled: true });
|
|
151
|
+
|
|
152
|
+
const keys = await apiKeyModel.query();
|
|
153
|
+
expect(keys).toHaveLength(2);
|
|
154
|
+
expect(keys[0].key).toMatch(/^lb-[\da-z]{16}$/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should query API keys ordered by updatedAt desc', async () => {
|
|
158
|
+
const key1 = await apiKeyModel.create({ name: 'Key 1', enabled: true });
|
|
159
|
+
// Wait a bit to ensure different timestamps
|
|
160
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
161
|
+
const key2 = await apiKeyModel.create({ name: 'Key 2', enabled: true });
|
|
162
|
+
|
|
163
|
+
const keys = await apiKeyModel.query();
|
|
164
|
+
expect(keys).toHaveLength(2);
|
|
165
|
+
expect(keys[0].id).toBe(key2.id);
|
|
166
|
+
expect(keys[1].id).toBe(key1.id);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should query API keys with decryption', async () => {
|
|
170
|
+
const mockEncryptor = vi.fn().mockResolvedValue('encrypted-key');
|
|
171
|
+
const mockDecryptor = vi.fn().mockResolvedValue({ plaintext: 'decrypted-key-value' });
|
|
172
|
+
|
|
173
|
+
await apiKeyModel.create({ name: 'Encrypted Key', enabled: true }, mockEncryptor);
|
|
174
|
+
|
|
175
|
+
const keys = await apiKeyModel.query(mockDecryptor);
|
|
176
|
+
|
|
177
|
+
expect(keys).toHaveLength(1);
|
|
178
|
+
expect(keys[0].key).toBe('decrypted-key-value');
|
|
179
|
+
expect(mockDecryptor).toHaveBeenCalledWith('encrypted-key');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should only query API keys for the current user', async () => {
|
|
183
|
+
await apiKeyModel.create({ name: 'User 1 Key', enabled: true });
|
|
184
|
+
|
|
185
|
+
const anotherApiKeyModel = new ApiKeyModel(serverDB, 'user2');
|
|
186
|
+
await anotherApiKeyModel.create({ name: 'User 2 Key', enabled: true });
|
|
187
|
+
|
|
188
|
+
const keys = await apiKeyModel.query();
|
|
189
|
+
expect(keys).toHaveLength(1);
|
|
190
|
+
expect(keys[0].name).toBe('User 1 Key');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('findByKey', () => {
|
|
195
|
+
it('should find API key by key value without encryption', async () => {
|
|
196
|
+
// Use a valid hex format key since validateApiKeyFormat checks for hex pattern
|
|
197
|
+
const validKey = 'lb-abcdef0123456789';
|
|
198
|
+
await serverDB.insert(apiKeys).values({
|
|
199
|
+
enabled: true,
|
|
200
|
+
key: validKey,
|
|
201
|
+
name: 'Test Key',
|
|
202
|
+
userId,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const found = await apiKeyModel.findByKey(validKey);
|
|
206
|
+
|
|
207
|
+
expect(found).toBeDefined();
|
|
208
|
+
expect(found?.key).toBe(validKey);
|
|
209
|
+
expect(found?.name).toBe('Test Key');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should find API key by key value with encryption', async () => {
|
|
213
|
+
const mockEncryptor = vi.fn().mockResolvedValue('encrypted-key-value');
|
|
214
|
+
const created = await apiKeyModel.create({ name: 'Test Key', enabled: true }, mockEncryptor);
|
|
215
|
+
|
|
216
|
+
const testKey = 'lb-0123456789abcdef';
|
|
217
|
+
mockEncryptor.mockResolvedValue('encrypted-key-value');
|
|
218
|
+
const found = await apiKeyModel.findByKey(testKey, mockEncryptor);
|
|
219
|
+
|
|
220
|
+
expect(mockEncryptor).toHaveBeenCalledWith(testKey);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should return null for invalid key format', async () => {
|
|
224
|
+
const found = await apiKeyModel.findByKey('invalid-key-format');
|
|
225
|
+
|
|
226
|
+
expect(found).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should return undefined for non-existent key', async () => {
|
|
230
|
+
const found = await apiKeyModel.findByKey('lb-0123456789abcdef');
|
|
231
|
+
|
|
232
|
+
expect(found).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('validateKey', () => {
|
|
237
|
+
it('should validate enabled and non-expired key with valid hex format', async () => {
|
|
238
|
+
const futureDate = new Date();
|
|
239
|
+
futureDate.setFullYear(futureDate.getFullYear() + 1);
|
|
240
|
+
|
|
241
|
+
// Use a valid hex format key
|
|
242
|
+
const validKey = 'lb-0123456789abcdef';
|
|
243
|
+
await serverDB.insert(apiKeys).values({
|
|
244
|
+
enabled: true,
|
|
245
|
+
expiresAt: futureDate,
|
|
246
|
+
key: validKey,
|
|
247
|
+
name: 'Valid Key',
|
|
248
|
+
userId,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const isValid = await apiKeyModel.validateKey(validKey);
|
|
252
|
+
|
|
253
|
+
expect(isValid).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should validate enabled key without expiration with valid hex format', async () => {
|
|
257
|
+
// Use a valid hex format key
|
|
258
|
+
const validKey = 'lb-fedcba9876543210';
|
|
259
|
+
await serverDB.insert(apiKeys).values({
|
|
260
|
+
enabled: true,
|
|
261
|
+
key: validKey,
|
|
262
|
+
name: 'Valid Key',
|
|
263
|
+
userId,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const isValid = await apiKeyModel.validateKey(validKey);
|
|
267
|
+
|
|
268
|
+
expect(isValid).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should reject non-existent key', async () => {
|
|
272
|
+
const isValid = await apiKeyModel.validateKey('lb-0123456789abcdef');
|
|
273
|
+
|
|
274
|
+
expect(isValid).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should reject disabled key', async () => {
|
|
278
|
+
const validKey = 'lb-1111111111111111';
|
|
279
|
+
await serverDB.insert(apiKeys).values({
|
|
280
|
+
enabled: false,
|
|
281
|
+
key: validKey,
|
|
282
|
+
name: 'Disabled Key',
|
|
283
|
+
userId,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const isValid = await apiKeyModel.validateKey(validKey);
|
|
287
|
+
|
|
288
|
+
expect(isValid).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should reject expired key', async () => {
|
|
292
|
+
const pastDate = new Date();
|
|
293
|
+
pastDate.setFullYear(pastDate.getFullYear() - 1);
|
|
294
|
+
|
|
295
|
+
const validKey = 'lb-2222222222222222';
|
|
296
|
+
await serverDB.insert(apiKeys).values({
|
|
297
|
+
enabled: true,
|
|
298
|
+
expiresAt: pastDate,
|
|
299
|
+
key: validKey,
|
|
300
|
+
name: 'Expired Key',
|
|
301
|
+
userId,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const isValid = await apiKeyModel.validateKey(validKey);
|
|
305
|
+
|
|
306
|
+
expect(isValid).toBe(false);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should reject invalid key format', async () => {
|
|
310
|
+
const isValid = await apiKeyModel.validateKey('invalid-format');
|
|
311
|
+
|
|
312
|
+
expect(isValid).toBe(false);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('update', () => {
|
|
317
|
+
it('should update API key properties', async () => {
|
|
318
|
+
const { id } = await apiKeyModel.create({ name: 'Test Key', enabled: true });
|
|
319
|
+
|
|
320
|
+
await apiKeyModel.update(id, { name: 'Updated Key', enabled: false });
|
|
321
|
+
|
|
322
|
+
const updated = await serverDB.query.apiKeys.findFirst({
|
|
323
|
+
where: eq(apiKeys.id, id),
|
|
324
|
+
});
|
|
325
|
+
expect(updated).toMatchObject({
|
|
326
|
+
enabled: false,
|
|
327
|
+
id,
|
|
328
|
+
name: 'Updated Key',
|
|
329
|
+
userId,
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should update expiration date', async () => {
|
|
334
|
+
const { id } = await apiKeyModel.create({ name: 'Test Key', enabled: true });
|
|
335
|
+
|
|
336
|
+
const newExpiresAt = new Date('2026-12-31');
|
|
337
|
+
await apiKeyModel.update(id, { expiresAt: newExpiresAt });
|
|
338
|
+
|
|
339
|
+
const updated = await serverDB.query.apiKeys.findFirst({
|
|
340
|
+
where: eq(apiKeys.id, id),
|
|
341
|
+
});
|
|
342
|
+
expect(updated?.expiresAt).toEqual(newExpiresAt);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should only update API keys for the current user', async () => {
|
|
346
|
+
const { id: key1 } = await apiKeyModel.create({ name: 'User 1 Key', enabled: true });
|
|
347
|
+
|
|
348
|
+
const anotherApiKeyModel = new ApiKeyModel(serverDB, 'user2');
|
|
349
|
+
const { id: key2 } = await anotherApiKeyModel.create({ name: 'User 2 Key', enabled: true });
|
|
350
|
+
|
|
351
|
+
await apiKeyModel.update(key2, { name: 'Attempted Update' });
|
|
352
|
+
|
|
353
|
+
const key2Still = await serverDB.query.apiKeys.findFirst({
|
|
354
|
+
where: eq(apiKeys.id, key2),
|
|
355
|
+
});
|
|
356
|
+
expect(key2Still?.name).toBe('User 2 Key');
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe('findById', () => {
|
|
361
|
+
it('should find API key by id', async () => {
|
|
362
|
+
const { id } = await apiKeyModel.create({ name: 'Test Key', enabled: true });
|
|
363
|
+
|
|
364
|
+
const found = await apiKeyModel.findById(id);
|
|
365
|
+
|
|
366
|
+
expect(found).toMatchObject({
|
|
367
|
+
enabled: true,
|
|
368
|
+
id,
|
|
369
|
+
name: 'Test Key',
|
|
370
|
+
userId,
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should return undefined for non-existent id', async () => {
|
|
375
|
+
const found = await apiKeyModel.findById(999_999);
|
|
376
|
+
|
|
377
|
+
expect(found).toBeUndefined();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('should only find API keys for the current user', async () => {
|
|
381
|
+
const anotherApiKeyModel = new ApiKeyModel(serverDB, 'user2');
|
|
382
|
+
const { id: key2 } = await anotherApiKeyModel.create({ name: 'User 2 Key', enabled: true });
|
|
383
|
+
|
|
384
|
+
const found = await apiKeyModel.findById(key2);
|
|
385
|
+
|
|
386
|
+
expect(found).toBeUndefined();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe('updateLastUsed', () => {
|
|
391
|
+
it('should update lastUsedAt timestamp', async () => {
|
|
392
|
+
const { id } = await apiKeyModel.create({ name: 'Test Key', enabled: true });
|
|
393
|
+
|
|
394
|
+
const beforeUpdate = await serverDB.query.apiKeys.findFirst({
|
|
395
|
+
where: eq(apiKeys.id, id),
|
|
396
|
+
});
|
|
397
|
+
expect(beforeUpdate?.lastUsedAt).toBeNull();
|
|
398
|
+
|
|
399
|
+
await apiKeyModel.updateLastUsed(id);
|
|
400
|
+
|
|
401
|
+
const afterUpdate = await serverDB.query.apiKeys.findFirst({
|
|
402
|
+
where: eq(apiKeys.id, id),
|
|
403
|
+
});
|
|
404
|
+
expect(afterUpdate?.lastUsedAt).toBeInstanceOf(Date);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should only update API keys for the current user', async () => {
|
|
408
|
+
const anotherApiKeyModel = new ApiKeyModel(serverDB, 'user2');
|
|
409
|
+
const { id: key2 } = await anotherApiKeyModel.create({ name: 'User 2 Key', enabled: true });
|
|
410
|
+
|
|
411
|
+
await apiKeyModel.updateLastUsed(key2);
|
|
412
|
+
|
|
413
|
+
const key2Still = await serverDB.query.apiKeys.findFirst({
|
|
414
|
+
where: eq(apiKeys.id, key2),
|
|
415
|
+
});
|
|
416
|
+
expect(key2Still?.lastUsedAt).toBeNull();
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('should update existing lastUsedAt to a new timestamp', async () => {
|
|
420
|
+
const { id } = await apiKeyModel.create({ name: 'Test Key', enabled: true });
|
|
421
|
+
|
|
422
|
+
await apiKeyModel.updateLastUsed(id);
|
|
423
|
+
|
|
424
|
+
const firstUpdate = await serverDB.query.apiKeys.findFirst({
|
|
425
|
+
where: eq(apiKeys.id, id),
|
|
426
|
+
});
|
|
427
|
+
const firstTimestamp = firstUpdate?.lastUsedAt;
|
|
428
|
+
|
|
429
|
+
// Wait to ensure different timestamp
|
|
430
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
431
|
+
|
|
432
|
+
await apiKeyModel.updateLastUsed(id);
|
|
433
|
+
|
|
434
|
+
const secondUpdate = await serverDB.query.apiKeys.findFirst({
|
|
435
|
+
where: eq(apiKeys.id, id),
|
|
436
|
+
});
|
|
437
|
+
const secondTimestamp = secondUpdate?.lastUsedAt;
|
|
438
|
+
|
|
439
|
+
expect(secondTimestamp).toBeDefined();
|
|
440
|
+
expect(firstTimestamp).toBeDefined();
|
|
441
|
+
expect(secondTimestamp!.getTime()).toBeGreaterThan(firstTimestamp!.getTime());
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
});
|