@lobehub/lobehub 2.0.0-next.32 → 2.0.0-next.33
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/.github/workflows/test.yml +1 -0
- package/CHANGELOG.md +33 -0
- package/apps/desktop/package.json +1 -1
- package/changelog/v1.json +12 -0
- package/docker-compose/local/.env.example +3 -0
- package/docs/self-hosting/server-database/docker-compose.mdx +29 -0
- package/docs/self-hosting/server-database/docker-compose.zh-CN.mdx +29 -0
- package/package.json +1 -1
- package/packages/const/src/hotkeys.ts +3 -3
- package/packages/const/src/models.ts +2 -2
- package/packages/const/src/utils/merge.ts +3 -3
- package/packages/conversation-flow/package.json +13 -0
- package/packages/conversation-flow/src/__tests__/fixtures/index.ts +48 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/assistant-chain-with-followup.json +56 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/assistant-with-tools.json +144 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/active-index-1.json +131 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/assistant-branch.json +96 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/assistant-user-branch.json +123 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/conversation.json +128 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/index.ts +14 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/branch/nested.json +179 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/compare/index.ts +8 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/compare/simple.json +85 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/compare/with-tools.json +169 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/complex-scenario.json +107 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/index.ts +14 -0
- package/packages/conversation-flow/src/__tests__/fixtures/inputs/linear-conversation.json +59 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/assistant-chain-with-followup.json +135 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/assistant-with-tools.json +340 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/active-index-1.json +242 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/assistant-branch.json +208 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/assistant-user-branch.json +254 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/conversation.json +260 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/index.ts +14 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/branch/nested.json +389 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/compare/index.ts +8 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/compare/simple.json +224 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/compare/with-tools.json +418 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/complex-scenario.json +239 -0
- package/packages/conversation-flow/src/__tests__/fixtures/outputs/linear-conversation.json +138 -0
- package/packages/conversation-flow/src/__tests__/parse.test.ts +97 -0
- package/packages/conversation-flow/src/index.ts +17 -0
- package/packages/conversation-flow/src/indexing.ts +58 -0
- package/packages/conversation-flow/src/parse.ts +53 -0
- package/packages/conversation-flow/src/structuring.ts +38 -0
- package/packages/conversation-flow/src/transformation/BranchResolver.ts +66 -0
- package/packages/conversation-flow/src/transformation/ContextTreeBuilder.ts +292 -0
- package/packages/conversation-flow/src/transformation/FlatListBuilder.ts +421 -0
- package/packages/conversation-flow/src/transformation/MessageCollector.ts +166 -0
- package/packages/conversation-flow/src/transformation/MessageTransformer.ts +177 -0
- package/packages/conversation-flow/src/transformation/__tests__/BranchResolver.test.ts +151 -0
- package/packages/conversation-flow/src/transformation/__tests__/ContextTreeBuilder.test.ts +385 -0
- package/packages/conversation-flow/src/transformation/__tests__/FlatListBuilder.test.ts +511 -0
- package/packages/conversation-flow/src/transformation/__tests__/MessageCollector.test.ts +220 -0
- package/packages/conversation-flow/src/transformation/__tests__/MessageTransformer.test.ts +287 -0
- package/packages/conversation-flow/src/transformation/index.ts +78 -0
- package/packages/conversation-flow/src/types/contextTree.ts +65 -0
- package/packages/conversation-flow/src/types/flatMessageList.ts +66 -0
- package/packages/conversation-flow/src/types/shared.ts +63 -0
- package/packages/conversation-flow/src/types.ts +36 -0
- package/packages/conversation-flow/vitest.config.mts +10 -0
- package/packages/types/src/message/common/metadata.ts +5 -1
- package/src/app/[variants]/(main)/chat/@session/features/SessionListContent/List/Item/index.tsx +3 -4
- package/src/envs/__tests__/app.test.ts +47 -13
- package/src/envs/app.ts +6 -0
- package/src/server/routers/async/__tests__/caller.test.ts +333 -0
- package/src/server/routers/async/caller.ts +2 -1
- package/src/server/routers/lambda/__tests__/integration/message.integration.test.ts +57 -57
- package/src/server/routers/lambda/message.ts +2 -2
- package/src/server/services/message/__tests__/index.test.ts +4 -4
- package/src/server/services/message/index.ts +1 -1
- package/src/services/message/index.ts +2 -3
- package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChat.test.ts +8 -8
- package/src/store/chat/slices/aiChat/actions/__tests__/generateAIChatV2.test.ts +8 -8
- package/src/store/chat/slices/aiChat/actions/__tests__/helpers.ts +1 -1
- package/src/store/chat/slices/aiChat/actions/generateAIChatV2.ts +1 -1
- package/src/store/chat/slices/message/action.test.ts +7 -7
- package/src/store/chat/slices/message/action.ts +2 -2
- package/src/store/chat/slices/plugin/action.test.ts +7 -7
- package/src/store/chat/slices/plugin/action.ts +1 -1
- package/packages/context-engine/ARCHITECTURE.md +0 -425
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { UIChatMessage } from '@lobechat/types';
|
|
2
|
+
|
|
3
|
+
import type { ContextNode } from './contextTree';
|
|
4
|
+
import type { FlatMessage } from './flatMessageList';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared Types
|
|
8
|
+
*
|
|
9
|
+
* Common types used across the conversation flow engine.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Re-export UIChatMessage as Message for convenience
|
|
14
|
+
*/
|
|
15
|
+
export type Message = UIChatMessage;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Message group metadata from database
|
|
19
|
+
* Used for multi-model parallel conversations and manual grouping
|
|
20
|
+
*/
|
|
21
|
+
export interface MessageGroupMetadata {
|
|
22
|
+
description?: string;
|
|
23
|
+
id: string;
|
|
24
|
+
/** Presentation mode: compare, summary, or manual */
|
|
25
|
+
mode?: 'compare' | 'summary' | 'manual';
|
|
26
|
+
/** Parent message that triggered this group */
|
|
27
|
+
parentMessageId?: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Internal structure node used during tree building
|
|
33
|
+
*/
|
|
34
|
+
export interface IdNode {
|
|
35
|
+
children: IdNode[];
|
|
36
|
+
id: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Result of the parse function
|
|
41
|
+
*/
|
|
42
|
+
export interface ParseResult {
|
|
43
|
+
/** Semantic tree structure for navigation and context understanding */
|
|
44
|
+
contextTree: ContextNode[];
|
|
45
|
+
/** Flattened list optimized for virtual list rendering */
|
|
46
|
+
flatList: FlatMessage[];
|
|
47
|
+
/** Map for O(1) message access */
|
|
48
|
+
messageMap: Record<string, Message>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Internal helper maps used during parsing
|
|
53
|
+
*/
|
|
54
|
+
export interface HelperMaps {
|
|
55
|
+
/** Maps parent ID to array of child IDs */
|
|
56
|
+
childrenMap: Map<string | null, string[]>;
|
|
57
|
+
/** Maps message group ID to its metadata */
|
|
58
|
+
messageGroupMap: Map<string, MessageGroupMetadata>;
|
|
59
|
+
/** Maps message ID to message */
|
|
60
|
+
messageMap: Map<string, Message>;
|
|
61
|
+
/** Maps thread ID to all messages in that thread */
|
|
62
|
+
threadMap: Map<string, Message[]>;
|
|
63
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type Index
|
|
3
|
+
*
|
|
4
|
+
* Centralized exports for all conversation flow types.
|
|
5
|
+
* Types are organized into three categories:
|
|
6
|
+
*
|
|
7
|
+
* 1. Context Tree (types/contextTree.ts) - Tree structure for navigation
|
|
8
|
+
* 2. Flat Message List (types/flatMessageList.ts) - Optimized for rendering
|
|
9
|
+
* 3. Shared (types/shared.ts) - Common types used across modules
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Context Tree Types
|
|
13
|
+
export type {
|
|
14
|
+
AssistantGroupNode,
|
|
15
|
+
BranchNode,
|
|
16
|
+
CompareNode,
|
|
17
|
+
ContextNode,
|
|
18
|
+
MessageNode,
|
|
19
|
+
} from './types/contextTree';
|
|
20
|
+
|
|
21
|
+
// Flat Message List Types
|
|
22
|
+
export type {
|
|
23
|
+
BranchMetadata,
|
|
24
|
+
FlatMessage,
|
|
25
|
+
FlatMessageExtra,
|
|
26
|
+
FlatMessageRole,
|
|
27
|
+
} from './types/flatMessageList';
|
|
28
|
+
|
|
29
|
+
// Shared Types
|
|
30
|
+
export type {
|
|
31
|
+
HelperMaps,
|
|
32
|
+
IdNode,
|
|
33
|
+
Message,
|
|
34
|
+
MessageGroupMetadata,
|
|
35
|
+
ParseResult,
|
|
36
|
+
} from './types/shared';
|
|
@@ -103,4 +103,8 @@ export interface ModelPerformance {
|
|
|
103
103
|
latency?: number;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
export interface MessageMetadata extends ModelUsage, ModelPerformance {
|
|
106
|
+
export interface MessageMetadata extends ModelUsage, ModelPerformance {
|
|
107
|
+
activeBranchIndex?: number;
|
|
108
|
+
activeColumn?: boolean;
|
|
109
|
+
compare?: boolean;
|
|
110
|
+
}
|
package/src/app/[variants]/(main)/chat/@session/features/SessionListContent/List/Item/index.tsx
CHANGED
|
@@ -4,9 +4,8 @@ import { Flexbox } from 'react-layout-kit';
|
|
|
4
4
|
import { shallow } from 'zustand/shallow';
|
|
5
5
|
|
|
6
6
|
import { DEFAULT_AVATAR } from '@/const/meta';
|
|
7
|
+
import { INBOX_SESSION_ID } from '@/const/session';
|
|
7
8
|
import { isDesktop } from '@/const/version';
|
|
8
|
-
import { useAgentStore } from '@/store/agent';
|
|
9
|
-
import { agentSelectors } from '@/store/agent/selectors';
|
|
10
9
|
import { useChatStore } from '@/store/chat';
|
|
11
10
|
import { messageStateSelectors } from '@/store/chat/selectors';
|
|
12
11
|
import { useGlobalStore } from '@/store/global';
|
|
@@ -28,7 +27,6 @@ interface SessionItemProps {
|
|
|
28
27
|
const SessionItem = memo<SessionItemProps>(({ id }) => {
|
|
29
28
|
const [open, setOpen] = useState(false);
|
|
30
29
|
const [createGroupModalOpen, setCreateGroupModalOpen] = useState(false);
|
|
31
|
-
const [defaultModel] = useAgentStore((s) => [agentSelectors.inboxAgentModel(s)]);
|
|
32
30
|
|
|
33
31
|
const openSessionInNewWindow = useGlobalStore((s) => s.openSessionInNewWindow);
|
|
34
32
|
|
|
@@ -55,7 +53,8 @@ const SessionItem = memo<SessionItemProps>(({ id }) => {
|
|
|
55
53
|
];
|
|
56
54
|
});
|
|
57
55
|
|
|
58
|
-
|
|
56
|
+
// Only hide the model tag for the inbox session itself (随便聊聊)
|
|
57
|
+
const showModel = sessionType === 'agent' && model && id !== INBOX_SESSION_ID;
|
|
59
58
|
|
|
60
59
|
const handleDoubleClick = () => {
|
|
61
60
|
if (isDesktop) {
|
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
// @vitest-environment node
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
|
|
4
|
-
import { getAppConfig } from '../app';
|
|
5
|
-
|
|
6
|
-
// Stub the global process object to safely mock environment variables
|
|
7
|
-
vi.stubGlobal('process', {
|
|
8
|
-
...process, // Preserve the original process object
|
|
9
|
-
env: { ...process.env }, // Clone the environment variables object for modification
|
|
10
|
-
});
|
|
11
|
-
|
|
12
4
|
describe('getServerConfig', () => {
|
|
13
5
|
beforeEach(() => {
|
|
14
|
-
// Reset
|
|
15
|
-
vi.
|
|
6
|
+
// Reset modules to clear the cached config
|
|
7
|
+
vi.resetModules();
|
|
16
8
|
});
|
|
17
9
|
|
|
18
10
|
// it('correctly handles values for OPENAI_FUNCTION_REGIONS', () => {
|
|
@@ -22,7 +14,8 @@ describe('getServerConfig', () => {
|
|
|
22
14
|
// });
|
|
23
15
|
|
|
24
16
|
describe('index url', () => {
|
|
25
|
-
it('should return default URLs when no environment variables are set', () => {
|
|
17
|
+
it('should return default URLs when no environment variables are set', async () => {
|
|
18
|
+
const { getAppConfig } = await import('../app');
|
|
26
19
|
const config = getAppConfig();
|
|
27
20
|
expect(config.AGENTS_INDEX_URL).toBe(
|
|
28
21
|
'https://registry.npmmirror.com/@lobehub/agents-index/v1/files/public',
|
|
@@ -32,18 +25,20 @@ describe('getServerConfig', () => {
|
|
|
32
25
|
);
|
|
33
26
|
});
|
|
34
27
|
|
|
35
|
-
it('should return custom URLs when environment variables are set', () => {
|
|
28
|
+
it('should return custom URLs when environment variables are set', async () => {
|
|
36
29
|
process.env.AGENTS_INDEX_URL = 'https://custom-agents-url.com';
|
|
37
30
|
process.env.PLUGINS_INDEX_URL = 'https://custom-plugins-url.com';
|
|
31
|
+
const { getAppConfig } = await import('../app');
|
|
38
32
|
const config = getAppConfig();
|
|
39
33
|
expect(config.AGENTS_INDEX_URL).toBe('https://custom-agents-url.com');
|
|
40
34
|
expect(config.PLUGINS_INDEX_URL).toBe('https://custom-plugins-url.com');
|
|
41
35
|
});
|
|
42
36
|
|
|
43
|
-
it('should return default URLs when environment variables are empty string', () => {
|
|
37
|
+
it('should return default URLs when environment variables are empty string', async () => {
|
|
44
38
|
process.env.AGENTS_INDEX_URL = '';
|
|
45
39
|
process.env.PLUGINS_INDEX_URL = '';
|
|
46
40
|
|
|
41
|
+
const { getAppConfig } = await import('../app');
|
|
47
42
|
const config = getAppConfig();
|
|
48
43
|
expect(config.AGENTS_INDEX_URL).toBe(
|
|
49
44
|
'https://registry.npmmirror.com/@lobehub/agents-index/v1/files/public',
|
|
@@ -53,4 +48,43 @@ describe('getServerConfig', () => {
|
|
|
53
48
|
);
|
|
54
49
|
});
|
|
55
50
|
});
|
|
51
|
+
|
|
52
|
+
describe('INTERNAL_APP_URL', () => {
|
|
53
|
+
it('should default to APP_URL when INTERNAL_APP_URL is not set', async () => {
|
|
54
|
+
process.env.APP_URL = 'https://example.com';
|
|
55
|
+
delete process.env.INTERNAL_APP_URL;
|
|
56
|
+
|
|
57
|
+
const { getAppConfig } = await import('../app');
|
|
58
|
+
const config = getAppConfig();
|
|
59
|
+
expect(config.INTERNAL_APP_URL).toBe('https://example.com');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should use INTERNAL_APP_URL when explicitly set', async () => {
|
|
63
|
+
process.env.APP_URL = 'https://public.example.com';
|
|
64
|
+
process.env.INTERNAL_APP_URL = 'http://localhost:3210';
|
|
65
|
+
|
|
66
|
+
const { getAppConfig } = await import('../app');
|
|
67
|
+
const config = getAppConfig();
|
|
68
|
+
expect(config.INTERNAL_APP_URL).toBe('http://localhost:3210');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should use INTERNAL_APP_URL over APP_URL when both are set', async () => {
|
|
72
|
+
process.env.APP_URL = 'https://public.example.com';
|
|
73
|
+
process.env.INTERNAL_APP_URL = 'http://internal-service:3210';
|
|
74
|
+
|
|
75
|
+
const { getAppConfig } = await import('../app');
|
|
76
|
+
const config = getAppConfig();
|
|
77
|
+
expect(config.APP_URL).toBe('https://public.example.com');
|
|
78
|
+
expect(config.INTERNAL_APP_URL).toBe('http://internal-service:3210');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should handle localhost INTERNAL_APP_URL for bypassing CDN', async () => {
|
|
82
|
+
process.env.APP_URL = 'https://cloudflare-proxied.com';
|
|
83
|
+
process.env.INTERNAL_APP_URL = 'http://127.0.0.1:3210';
|
|
84
|
+
|
|
85
|
+
const { getAppConfig } = await import('../app');
|
|
86
|
+
const config = getAppConfig();
|
|
87
|
+
expect(config.INTERNAL_APP_URL).toBe('http://127.0.0.1:3210');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
56
90
|
});
|
package/src/envs/app.ts
CHANGED
|
@@ -20,6 +20,10 @@ const APP_URL = process.env.APP_URL
|
|
|
20
20
|
? vercelUrl
|
|
21
21
|
: 'http://localhost:3010';
|
|
22
22
|
|
|
23
|
+
// INTERNAL_APP_URL is used for server-to-server calls to bypass CDN/proxy
|
|
24
|
+
// Falls back to APP_URL if not set
|
|
25
|
+
const INTERNAL_APP_URL = process.env.INTERNAL_APP_URL || APP_URL;
|
|
26
|
+
|
|
23
27
|
const ASSISTANT_INDEX_URL = 'https://registry.npmmirror.com/@lobehub/agents-index/v1/files/public';
|
|
24
28
|
|
|
25
29
|
const PLUGINS_INDEX_URL = 'https://registry.npmmirror.com/@lobehub/plugins-index/v1/files/public';
|
|
@@ -43,6 +47,7 @@ export const getAppConfig = () => {
|
|
|
43
47
|
PLUGIN_SETTINGS: z.string().optional(),
|
|
44
48
|
|
|
45
49
|
APP_URL: z.string().optional(),
|
|
50
|
+
INTERNAL_APP_URL: z.string().optional(),
|
|
46
51
|
VERCEL_EDGE_CONFIG: z.string().optional(),
|
|
47
52
|
MIDDLEWARE_REWRITE_THROUGH_LOCAL: z.boolean().optional(),
|
|
48
53
|
ENABLE_AUTH_PROTECTION: z.boolean().optional(),
|
|
@@ -77,6 +82,7 @@ export const getAppConfig = () => {
|
|
|
77
82
|
VERCEL_EDGE_CONFIG: process.env.VERCEL_EDGE_CONFIG,
|
|
78
83
|
|
|
79
84
|
APP_URL,
|
|
85
|
+
INTERNAL_APP_URL,
|
|
80
86
|
MIDDLEWARE_REWRITE_THROUGH_LOCAL: process.env.MIDDLEWARE_REWRITE_THROUGH_LOCAL === '1',
|
|
81
87
|
ENABLE_AUTH_PROTECTION: process.env.ENABLE_AUTH_PROTECTION === '1',
|
|
82
88
|
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
// Import the module under test after mocks are set up
|
|
3
|
+
import { createTRPCClient, httpLink } from '@trpc/client';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
|
|
7
|
+
|
|
8
|
+
import { createAsyncServerClient } from '../caller';
|
|
9
|
+
|
|
10
|
+
// Create mockable appEnv - use object property to allow mutation
|
|
11
|
+
const mockAppEnv: { APP_URL?: string; INTERNAL_APP_URL?: string | null | undefined } = {
|
|
12
|
+
APP_URL: 'https://public.example.com',
|
|
13
|
+
INTERNAL_APP_URL: 'http://localhost:3210',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Mock dependencies before importing the module under test
|
|
17
|
+
vi.mock('@trpc/client', () => ({
|
|
18
|
+
createTRPCClient: vi.fn(),
|
|
19
|
+
httpLink: vi.fn((options) => options),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('@/envs/app', () => ({
|
|
23
|
+
get appEnv() {
|
|
24
|
+
return mockAppEnv;
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('@/server/modules/KeyVaultsEncrypt', () => ({
|
|
29
|
+
KeyVaultsGateKeeper: {
|
|
30
|
+
initWithEnvKey: vi.fn(),
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock('@/config/db', () => ({
|
|
35
|
+
serverDBEnv: {
|
|
36
|
+
KEY_VAULTS_SECRET: 'test-secret-key',
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('@/const/version', () => ({
|
|
41
|
+
isDesktop: false,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
describe('createAsyncServerClient - INTERNAL_APP_URL Tests', () => {
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
|
|
48
|
+
const mockEncrypt = vi.fn().mockResolvedValue('encrypted-payload-data');
|
|
49
|
+
vi.mocked(KeyVaultsGateKeeper.initWithEnvKey).mockResolvedValue({
|
|
50
|
+
encrypt: mockEncrypt,
|
|
51
|
+
} as any);
|
|
52
|
+
vi.mocked(createTRPCClient).mockReturnValue({ _mockClient: true } as any);
|
|
53
|
+
|
|
54
|
+
// Reset to default values by mutating the object
|
|
55
|
+
mockAppEnv.APP_URL = 'https://public.example.com';
|
|
56
|
+
mockAppEnv.INTERNAL_APP_URL = 'http://localhost:3210';
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('URL selection logic', () => {
|
|
60
|
+
it('should use INTERNAL_APP_URL when both APP_URL and INTERNAL_APP_URL are set', async () => {
|
|
61
|
+
mockAppEnv.APP_URL = 'https://public.example.com';
|
|
62
|
+
mockAppEnv.INTERNAL_APP_URL = 'http://localhost:3210';
|
|
63
|
+
|
|
64
|
+
await createAsyncServerClient('user-123', { apiKey: 'test-key' });
|
|
65
|
+
|
|
66
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
67
|
+
const httpLinkOptions = config.links[0] as any;
|
|
68
|
+
|
|
69
|
+
expect(httpLinkOptions.url).toBe('http://localhost:3210/trpc/async');
|
|
70
|
+
expect(httpLinkOptions.url).not.toContain('public.example.com');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should fall back to APP_URL when INTERNAL_APP_URL is not set in env', async () => {
|
|
74
|
+
// Simulating the result of getInternalAppUrl() when INTERNAL_APP_URL is not in env
|
|
75
|
+
// In this case, appEnv.INTERNAL_APP_URL would equal appEnv.APP_URL
|
|
76
|
+
mockAppEnv.APP_URL = 'https://fallback.example.com';
|
|
77
|
+
mockAppEnv.INTERNAL_APP_URL = 'https://fallback.example.com'; // getInternalAppUrl() returns APP_URL
|
|
78
|
+
|
|
79
|
+
await createAsyncServerClient('user-456', {});
|
|
80
|
+
|
|
81
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
82
|
+
const httpLinkOptions = config.links[0] as any;
|
|
83
|
+
|
|
84
|
+
expect(httpLinkOptions.url).toBe('https://fallback.example.com/trpc/async');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should use localhost to bypass CDN proxy', async () => {
|
|
88
|
+
mockAppEnv.APP_URL = 'https://cdn-proxied.example.com';
|
|
89
|
+
mockAppEnv.INTERNAL_APP_URL = 'http://127.0.0.1:3210';
|
|
90
|
+
|
|
91
|
+
await createAsyncServerClient('user-789', {});
|
|
92
|
+
|
|
93
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
94
|
+
const httpLinkOptions = config.links[0] as any;
|
|
95
|
+
|
|
96
|
+
expect(httpLinkOptions.url).toBe('http://127.0.0.1:3210/trpc/async');
|
|
97
|
+
expect(httpLinkOptions.url).not.toContain('cdn-proxied');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should use internal service name in Docker network', async () => {
|
|
101
|
+
mockAppEnv.APP_URL = 'https://public.example.com';
|
|
102
|
+
mockAppEnv.INTERNAL_APP_URL = 'http://lobe-service:3210';
|
|
103
|
+
|
|
104
|
+
await createAsyncServerClient('user-docker', {});
|
|
105
|
+
|
|
106
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
107
|
+
const httpLinkOptions = config.links[0] as any;
|
|
108
|
+
|
|
109
|
+
expect(httpLinkOptions.url).toBe('http://lobe-service:3210/trpc/async');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should handle INTERNAL_APP_URL with trailing slash', async () => {
|
|
113
|
+
mockAppEnv.INTERNAL_APP_URL = 'http://localhost:3210/';
|
|
114
|
+
|
|
115
|
+
await createAsyncServerClient('user-trailing', {});
|
|
116
|
+
|
|
117
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
118
|
+
const httpLinkOptions = config.links[0] as any;
|
|
119
|
+
|
|
120
|
+
// urlJoin should normalize the trailing slash
|
|
121
|
+
expect(httpLinkOptions.url).toBe('http://localhost:3210/trpc/async');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should handle INTERNAL_APP_URL without trailing slash', async () => {
|
|
125
|
+
mockAppEnv.INTERNAL_APP_URL = 'https://example.com';
|
|
126
|
+
|
|
127
|
+
await createAsyncServerClient('user-no-trailing', {});
|
|
128
|
+
|
|
129
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
130
|
+
const httpLinkOptions = config.links[0] as any;
|
|
131
|
+
|
|
132
|
+
expect(httpLinkOptions.url).toBe('https://example.com/trpc/async');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('authentication and headers', () => {
|
|
137
|
+
it('should include Authorization header with KEY_VAULTS_SECRET', async () => {
|
|
138
|
+
await createAsyncServerClient('user-auth', {});
|
|
139
|
+
|
|
140
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
141
|
+
const httpLinkOptions = config.links[0] as any;
|
|
142
|
+
|
|
143
|
+
expect(httpLinkOptions.headers).toHaveProperty('Authorization');
|
|
144
|
+
expect(httpLinkOptions.headers.Authorization).toBe('Bearer test-secret-key');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should encrypt and include user payload in x-lobe-chat-auth header', async () => {
|
|
148
|
+
const testPayload = { apiKey: 'test-api-key-value', provider: 'openai' };
|
|
149
|
+
const mockEncrypt = vi.fn().mockResolvedValue('test-encrypted-auth-data');
|
|
150
|
+
vi.mocked(KeyVaultsGateKeeper.initWithEnvKey).mockResolvedValueOnce({
|
|
151
|
+
encrypt: mockEncrypt,
|
|
152
|
+
} as any);
|
|
153
|
+
|
|
154
|
+
await createAsyncServerClient('user-encrypt', testPayload);
|
|
155
|
+
|
|
156
|
+
expect(KeyVaultsGateKeeper.initWithEnvKey).toHaveBeenCalled();
|
|
157
|
+
expect(mockEncrypt).toHaveBeenCalledWith(
|
|
158
|
+
JSON.stringify({ payload: testPayload, userId: 'user-encrypt' }),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
162
|
+
const httpLinkOptions = config.links[0] as any;
|
|
163
|
+
|
|
164
|
+
// The header name is from LOBE_CHAT_AUTH_HEADER constant
|
|
165
|
+
expect(httpLinkOptions.headers).toHaveProperty('Authorization');
|
|
166
|
+
// The X-lobe-chat-auth header should be present
|
|
167
|
+
expect(Object.keys(httpLinkOptions.headers)).toContain('X-lobe-chat-auth');
|
|
168
|
+
expect(httpLinkOptions.headers['X-lobe-chat-auth']).toBe('test-encrypted-auth-data');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should include Vercel bypass secret when available', async () => {
|
|
172
|
+
const originalEnv = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
|
173
|
+
process.env.VERCEL_AUTOMATION_BYPASS_SECRET = 'test-bypass-value';
|
|
174
|
+
|
|
175
|
+
await createAsyncServerClient('user-vercel', {});
|
|
176
|
+
|
|
177
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
178
|
+
const httpLinkOptions = config.links[0] as any;
|
|
179
|
+
|
|
180
|
+
expect(httpLinkOptions.headers).toHaveProperty('x-vercel-protection-bypass');
|
|
181
|
+
expect(httpLinkOptions.headers['x-vercel-protection-bypass']).toBe('test-bypass-value');
|
|
182
|
+
|
|
183
|
+
// Restore original env
|
|
184
|
+
if (originalEnv) {
|
|
185
|
+
process.env.VERCEL_AUTOMATION_BYPASS_SECRET = originalEnv;
|
|
186
|
+
} else {
|
|
187
|
+
delete process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should not include Vercel bypass secret when not available', async () => {
|
|
192
|
+
delete process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
|
193
|
+
|
|
194
|
+
await createAsyncServerClient('user-no-vercel', {});
|
|
195
|
+
|
|
196
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
197
|
+
const httpLinkOptions = config.links[0] as any;
|
|
198
|
+
|
|
199
|
+
expect(httpLinkOptions.headers).not.toHaveProperty('x-vercel-protection-bypass');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('error handling', () => {
|
|
204
|
+
it('should handle encryption failure gracefully', async () => {
|
|
205
|
+
const mockEncrypt = vi.fn().mockRejectedValueOnce(new Error('Encryption failed'));
|
|
206
|
+
vi.mocked(KeyVaultsGateKeeper.initWithEnvKey).mockResolvedValueOnce({
|
|
207
|
+
encrypt: mockEncrypt,
|
|
208
|
+
} as any);
|
|
209
|
+
|
|
210
|
+
await expect(createAsyncServerClient('user-enc-fail', {})).rejects.toThrow(
|
|
211
|
+
'Encryption failed',
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(KeyVaultsGateKeeper.initWithEnvKey).toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should handle missing INTERNAL_APP_URL by using APP_URL', async () => {
|
|
218
|
+
// When INTERNAL_APP_URL is not set in env, getInternalAppUrl() returns APP_URL
|
|
219
|
+
mockAppEnv.APP_URL = 'https://only-app-url.com';
|
|
220
|
+
mockAppEnv.INTERNAL_APP_URL = 'https://only-app-url.com'; // Result of fallback
|
|
221
|
+
|
|
222
|
+
await createAsyncServerClient('user-null', {});
|
|
223
|
+
|
|
224
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
225
|
+
const httpLinkOptions = config.links[0] as any;
|
|
226
|
+
|
|
227
|
+
// Should use APP_URL when INTERNAL_APP_URL is not set in environment
|
|
228
|
+
expect(httpLinkOptions.url).toContain('only-app-url.com');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should handle empty string INTERNAL_APP_URL', async () => {
|
|
232
|
+
mockAppEnv.APP_URL = 'https://fallback-from-empty.com';
|
|
233
|
+
mockAppEnv.INTERNAL_APP_URL = '';
|
|
234
|
+
|
|
235
|
+
await createAsyncServerClient('user-empty', {});
|
|
236
|
+
|
|
237
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
238
|
+
const httpLinkOptions = config.links[0] as any;
|
|
239
|
+
|
|
240
|
+
// Empty string is falsy, so urlJoin will use it but result may vary
|
|
241
|
+
expect(httpLinkOptions.url).toBeDefined();
|
|
242
|
+
expect(httpLinkOptions.url).toContain('trpc/async');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should handle malformed URL gracefully', async () => {
|
|
246
|
+
mockAppEnv.INTERNAL_APP_URL = 'not-a-valid-url';
|
|
247
|
+
|
|
248
|
+
await createAsyncServerClient('user-malformed', {});
|
|
249
|
+
|
|
250
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
251
|
+
const httpLinkOptions = config.links[0] as any;
|
|
252
|
+
|
|
253
|
+
// urlJoin will still create a result, even if base is malformed
|
|
254
|
+
expect(httpLinkOptions.url).toBeDefined();
|
|
255
|
+
expect(httpLinkOptions.url).toContain('trpc/async');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('TRPC client configuration', () => {
|
|
260
|
+
it('should configure httpLink with proper options', async () => {
|
|
261
|
+
await createAsyncServerClient('user-config', {});
|
|
262
|
+
|
|
263
|
+
expect(httpLink).toHaveBeenCalled();
|
|
264
|
+
const httpLinkOptions = vi.mocked(httpLink).mock.calls[0][0];
|
|
265
|
+
|
|
266
|
+
expect(httpLinkOptions).toHaveProperty('url');
|
|
267
|
+
expect(httpLinkOptions).toHaveProperty('headers');
|
|
268
|
+
expect(httpLinkOptions).toHaveProperty('transformer');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('should pass httpLink result to createTRPCClient', async () => {
|
|
272
|
+
await createAsyncServerClient('user-link', {});
|
|
273
|
+
|
|
274
|
+
expect(createTRPCClient).toHaveBeenCalledWith({
|
|
275
|
+
links: expect.arrayContaining([
|
|
276
|
+
expect.objectContaining({
|
|
277
|
+
url: expect.any(String),
|
|
278
|
+
headers: expect.any(Object),
|
|
279
|
+
}),
|
|
280
|
+
]),
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('should return the created TRPC client', async () => {
|
|
285
|
+
const client = await createAsyncServerClient('user-return', {});
|
|
286
|
+
|
|
287
|
+
expect(client).toBeDefined();
|
|
288
|
+
expect(client).toHaveProperty('_mockClient');
|
|
289
|
+
expect((client as any)._mockClient).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('real-world scenarios', () => {
|
|
294
|
+
it('should handle production deployment behind Cloudflare', async () => {
|
|
295
|
+
mockAppEnv.APP_URL = 'https://lobechat.example.com';
|
|
296
|
+
mockAppEnv.INTERNAL_APP_URL = 'http://localhost:3210';
|
|
297
|
+
|
|
298
|
+
await createAsyncServerClient('prod-user', { apiKey: 'test-key' });
|
|
299
|
+
|
|
300
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
301
|
+
const httpLinkOptions = config.links[0] as any;
|
|
302
|
+
|
|
303
|
+
// Should use localhost to avoid CDN timeout
|
|
304
|
+
expect(httpLinkOptions.url).toBe('http://localhost:3210/trpc/async');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('should handle Docker Compose deployment with service names', async () => {
|
|
308
|
+
mockAppEnv.APP_URL = 'https://public.example.com';
|
|
309
|
+
mockAppEnv.INTERNAL_APP_URL = 'http://lobe-chat-database:3210';
|
|
310
|
+
|
|
311
|
+
await createAsyncServerClient('docker-user', {});
|
|
312
|
+
|
|
313
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
314
|
+
const httpLinkOptions = config.links[0] as any;
|
|
315
|
+
|
|
316
|
+
expect(httpLinkOptions.url).toBe('http://lobe-chat-database:3210/trpc/async');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('should handle deployment without CDN (INTERNAL_APP_URL not set)', async () => {
|
|
320
|
+
// When not using CDN, INTERNAL_APP_URL is not set, so it falls back to APP_URL
|
|
321
|
+
mockAppEnv.APP_URL = 'https://direct-access.example.com';
|
|
322
|
+
mockAppEnv.INTERNAL_APP_URL = 'https://direct-access.example.com'; // Result of getInternalAppUrl() fallback
|
|
323
|
+
|
|
324
|
+
await createAsyncServerClient('direct-user', {});
|
|
325
|
+
|
|
326
|
+
const config = vi.mocked(createTRPCClient).mock.calls[0][0];
|
|
327
|
+
const httpLinkOptions = config.links[0] as any;
|
|
328
|
+
|
|
329
|
+
// Should fallback to APP_URL
|
|
330
|
+
expect(httpLinkOptions.url).toBe('https://direct-access.example.com/trpc/async');
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -30,7 +30,8 @@ export const createAsyncServerClient = async (userId: string, payload: ClientSec
|
|
|
30
30
|
httpLink({
|
|
31
31
|
headers,
|
|
32
32
|
transformer: superjson,
|
|
33
|
-
|
|
33
|
+
// Use INTERNAL_APP_URL for server-to-server calls to bypass CDN/proxy
|
|
34
|
+
url: urlJoin(appEnv.INTERNAL_APP_URL!, '/trpc/async'),
|
|
34
35
|
}),
|
|
35
36
|
],
|
|
36
37
|
});
|