@lobehub/lobehub 2.0.0-next.41 → 2.0.0-next.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +1 -1
- package/packages/database/src/models/__tests__/messages/message.create.test.ts +75 -18
- package/packages/database/src/models/__tests__/messages/message.query.test.ts +223 -6
- package/packages/database/src/models/__tests__/messages/message.stats.test.ts +56 -7
- package/packages/database/src/models/__tests__/messages/message.update.test.ts +45 -4
- package/packages/database/src/models/message.ts +3 -5
- package/packages/utils/src/clientIP.ts +6 -6
- package/packages/utils/src/compressImage.ts +3 -3
- package/packages/utils/src/fetch/fetchSSE.ts +15 -15
- package/packages/utils/src/format.ts +2 -2
- package/packages/utils/src/merge.ts +3 -3
- package/packages/utils/src/parseModels.ts +3 -3
- package/packages/utils/src/sanitizeUTF8.ts +4 -4
- package/packages/utils/src/toolManifest.ts +4 -4
- package/packages/utils/src/trace.test.ts +359 -0
- package/packages/utils/src/uriParser.ts +4 -4
- package/src/features/ChatItem/components/Title.tsx +20 -16
- package/src/features/Conversation/Messages/Assistant/index.tsx +3 -2
- package/src/features/Conversation/Messages/Group/index.tsx +10 -3
- package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +8 -2
- package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +1 -4
- package/src/store/chat/slices/message/actions/optimisticUpdate.ts +1 -1
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* @param headers HTTP
|
|
2
|
+
* Get client IP address
|
|
3
|
+
* @param headers HTTP request headers
|
|
4
4
|
*/
|
|
5
5
|
export const getClientIP = (headers: Headers): string => {
|
|
6
|
-
//
|
|
6
|
+
// Check various IP headers in priority order
|
|
7
7
|
const ipHeaders = [
|
|
8
8
|
'cf-connecting-ip', // Cloudflare
|
|
9
9
|
'x-real-ip', // Nginx proxy
|
|
10
|
-
'x-forwarded-for', //
|
|
10
|
+
'x-forwarded-for', // Standard proxy header
|
|
11
11
|
'x-client-ip', // Apache
|
|
12
12
|
'true-client-ip', // Akamai and Cloudflare
|
|
13
|
-
'x-cluster-client-ip', //
|
|
13
|
+
'x-cluster-client-ip', // Load balancer
|
|
14
14
|
'forwarded', // RFC 7239
|
|
15
15
|
'fastly-client-ip', // Fastly CDN
|
|
16
16
|
'x-forwarded', // General forward
|
|
@@ -21,7 +21,7 @@ export const getClientIP = (headers: Headers): string => {
|
|
|
21
21
|
const value = headers.get(header);
|
|
22
22
|
if (!value) continue;
|
|
23
23
|
|
|
24
|
-
//
|
|
24
|
+
// Handle cases where multiple IPs may be present (e.g., x-forwarded-for)
|
|
25
25
|
if (header.toLowerCase() === 'x-forwarded-for') {
|
|
26
26
|
const firstIP = value.split(',')[0].trim();
|
|
27
27
|
if (firstIP) return firstIP;
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
const compressImage = ({ img, type = 'image/webp' }: { img: HTMLImageElement; type?: string }) => {
|
|
2
|
-
//
|
|
2
|
+
// Set maximum width and height
|
|
3
3
|
const maxWidth = 2160;
|
|
4
4
|
const maxHeight = 2160;
|
|
5
5
|
let width = img.width;
|
|
6
6
|
let height = img.height;
|
|
7
7
|
|
|
8
8
|
if (width > height && width > maxWidth) {
|
|
9
|
-
//
|
|
9
|
+
// If image width is greater than height and exceeds maximum width limit
|
|
10
10
|
width = maxWidth;
|
|
11
11
|
height = Math.round((maxWidth / img.width) * img.height);
|
|
12
12
|
} else if (height > width && height > maxHeight) {
|
|
13
|
-
//
|
|
13
|
+
// If image height is greater than width and exceeds maximum height limit
|
|
14
14
|
height = maxHeight;
|
|
15
15
|
width = Math.round((maxHeight / img.height) * img.width);
|
|
16
16
|
}
|
|
@@ -91,7 +91,7 @@ export interface FetchSSEOptions {
|
|
|
91
91
|
responseAnimation?: ResponseAnimation;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
const START_ANIMATION_SPEED = 10; //
|
|
94
|
+
const START_ANIMATION_SPEED = 10; // Default starting speed
|
|
95
95
|
|
|
96
96
|
const createSmoothMessage = (params: {
|
|
97
97
|
onTextUpdate: (delta: string, text: string) => void;
|
|
@@ -106,7 +106,7 @@ const createSmoothMessage = (params: {
|
|
|
106
106
|
let lastFrameTime = 0;
|
|
107
107
|
let accumulatedTime = 0;
|
|
108
108
|
let currentSpeed = startSpeed;
|
|
109
|
-
let lastQueueLength = 0; //
|
|
109
|
+
let lastQueueLength = 0; // Record the queue length from the previous frame
|
|
110
110
|
|
|
111
111
|
const stopAnimation = () => {
|
|
112
112
|
isAnimationActive = false;
|
|
@@ -127,7 +127,7 @@ const createSmoothMessage = (params: {
|
|
|
127
127
|
lastFrameTime = performance.now();
|
|
128
128
|
accumulatedTime = 0;
|
|
129
129
|
currentSpeed = speed;
|
|
130
|
-
lastQueueLength = 0; //
|
|
130
|
+
lastQueueLength = 0; // Reset previous frame queue length
|
|
131
131
|
|
|
132
132
|
const updateText = (timestamp: number) => {
|
|
133
133
|
if (!isAnimationActive) {
|
|
@@ -144,9 +144,9 @@ const createSmoothMessage = (params: {
|
|
|
144
144
|
|
|
145
145
|
let charsToProcess = 0;
|
|
146
146
|
if (outputQueue.length > 0) {
|
|
147
|
-
//
|
|
147
|
+
// Smoother speed adjustment
|
|
148
148
|
const targetSpeed = Math.max(speed, outputQueue.length);
|
|
149
|
-
//
|
|
149
|
+
// Adjust speed change rate based on queue length changes
|
|
150
150
|
const speedChangeRate = Math.abs(outputQueue.length - lastQueueLength) * 0.0008 + 0.005;
|
|
151
151
|
currentSpeed += (targetSpeed - currentSpeed) * speedChangeRate;
|
|
152
152
|
|
|
@@ -157,7 +157,7 @@ const createSmoothMessage = (params: {
|
|
|
157
157
|
accumulatedTime -= (charsToProcess * 1000) / currentSpeed;
|
|
158
158
|
|
|
159
159
|
let actualChars = Math.min(charsToProcess, outputQueue.length);
|
|
160
|
-
// actualChars = Math.min(speed, actualChars); //
|
|
160
|
+
// actualChars = Math.min(speed, actualChars); // Speed upper limit
|
|
161
161
|
|
|
162
162
|
// if (actualChars * 2 < outputQueue.length && /[\dA-Za-z]/.test(outputQueue[actualChars])) {
|
|
163
163
|
// actualChars *= 2;
|
|
@@ -168,7 +168,7 @@ const createSmoothMessage = (params: {
|
|
|
168
168
|
params.onTextUpdate(charsToAdd, buffer);
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
lastQueueLength = outputQueue.length; //
|
|
171
|
+
lastQueueLength = outputQueue.length; // Update previous frame queue length
|
|
172
172
|
|
|
173
173
|
if (outputQueue.length > 0 && isAnimationActive) {
|
|
174
174
|
animationFrameId = requestAnimationFrame(updateText);
|
|
@@ -219,7 +219,7 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
|
|
|
219
219
|
const shouldSkipTextProcessing = text === 'none';
|
|
220
220
|
const textSmoothing = text === 'smooth';
|
|
221
221
|
|
|
222
|
-
//
|
|
222
|
+
// Add text buffer and timer related variables
|
|
223
223
|
let textBuffer = '';
|
|
224
224
|
let bufferTimer: ReturnType<typeof setTimeout> | null = null;
|
|
225
225
|
const BUFFER_INTERVAL = 300; // 300ms
|
|
@@ -254,7 +254,7 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
|
|
|
254
254
|
let thinkingBuffer = '';
|
|
255
255
|
let thinkingBufferTimer: ReturnType<typeof setTimeout> | null = null;
|
|
256
256
|
|
|
257
|
-
//
|
|
257
|
+
// Create a function to handle buffer flushing
|
|
258
258
|
const flushThinkingBuffer = () => {
|
|
259
259
|
if (thinkingBuffer) {
|
|
260
260
|
options.onMessageHandle?.({ text: thinkingBuffer, type: 'reasoning' });
|
|
@@ -349,10 +349,10 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
|
|
|
349
349
|
} else {
|
|
350
350
|
output += data;
|
|
351
351
|
|
|
352
|
-
//
|
|
352
|
+
// Use buffer mechanism
|
|
353
353
|
textBuffer += data;
|
|
354
354
|
|
|
355
|
-
//
|
|
355
|
+
// If timer not set yet, create one
|
|
356
356
|
if (!bufferTimer) {
|
|
357
357
|
bufferTimer = setTimeout(() => {
|
|
358
358
|
flushTextBuffer();
|
|
@@ -395,10 +395,10 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
|
|
|
395
395
|
} else {
|
|
396
396
|
thinking += data;
|
|
397
397
|
|
|
398
|
-
//
|
|
398
|
+
// Use buffer mechanism
|
|
399
399
|
thinkingBuffer += data;
|
|
400
400
|
|
|
401
|
-
//
|
|
401
|
+
// If timer not set yet, create one
|
|
402
402
|
if (!thinkingBufferTimer) {
|
|
403
403
|
thinkingBufferTimer = setTimeout(() => {
|
|
404
404
|
flushThinkingBuffer();
|
|
@@ -421,7 +421,7 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
|
|
|
421
421
|
},
|
|
422
422
|
onopen: async (res) => {
|
|
423
423
|
response = res.clone();
|
|
424
|
-
//
|
|
424
|
+
// If not ok, it means there is a request error
|
|
425
425
|
if (!response.ok) {
|
|
426
426
|
throw await getMessageError(res);
|
|
427
427
|
}
|
|
@@ -434,7 +434,7 @@ export const fetchSSE = async (url: string, options: RequestInit & FetchSSEOptio
|
|
|
434
434
|
if (response) {
|
|
435
435
|
textController.stopAnimation();
|
|
436
436
|
|
|
437
|
-
//
|
|
437
|
+
// Ensure all buffered data is processed
|
|
438
438
|
if (bufferTimer) {
|
|
439
439
|
clearTimeout(bufferTimer);
|
|
440
440
|
flushTextBuffer();
|
|
@@ -64,10 +64,10 @@ export const formatShortenNumber = (num: any) => {
|
|
|
64
64
|
if (!num && num !== 0) return '--';
|
|
65
65
|
if (!isNumber(num)) return num;
|
|
66
66
|
|
|
67
|
-
//
|
|
67
|
+
// Use Intl.NumberFormat to add thousand separators
|
|
68
68
|
const formattedWithComma = new Intl.NumberFormat('en-US').format(num);
|
|
69
69
|
|
|
70
|
-
//
|
|
70
|
+
// Format as K or M
|
|
71
71
|
if (num >= 1_000_000) {
|
|
72
72
|
return (num / 1_000_000).toFixed(1) + 'M';
|
|
73
73
|
} else if (num >= 10_000) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { merge as _merge, isEmpty, mergeWith } from 'lodash-es';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Merge objects, directly replace if it's an array
|
|
5
5
|
* @param target
|
|
6
6
|
* @param source
|
|
7
7
|
*/
|
|
@@ -24,7 +24,7 @@ export const mergeArrayById = <T extends MergeableItem>(defaultItems: T[], userI
|
|
|
24
24
|
// Create a map of default items for faster lookup
|
|
25
25
|
const defaultItemsMap = new Map(defaultItems.map((item) => [item.id, item]));
|
|
26
26
|
|
|
27
|
-
//
|
|
27
|
+
// Use Map to store merged results, so duplicate IDs naturally overwrite previous entries
|
|
28
28
|
const mergedItemsMap = new Map<string, T>();
|
|
29
29
|
|
|
30
30
|
// Process user items with default metadata
|
|
@@ -51,7 +51,7 @@ export const mergeArrayById = <T extends MergeableItem>(defaultItems: T[], userI
|
|
|
51
51
|
mergedItemsMap.set(userItem.id, mergedItem);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
//
|
|
54
|
+
// Add items that only exist in default configuration
|
|
55
55
|
defaultItems.forEach((item) => {
|
|
56
56
|
if (!mergedItemsMap.has(item.id)) {
|
|
57
57
|
mergedItemsMap.set(item.id, item);
|
|
@@ -138,16 +138,16 @@ export const transformToAiModelList = async ({
|
|
|
138
138
|
const modelConfig = await parseModelString(providerId, modelString, withDeploymentName);
|
|
139
139
|
let chatModels = modelConfig.removeAll ? [] : defaultModels;
|
|
140
140
|
|
|
141
|
-
//
|
|
141
|
+
// Handle removal logic
|
|
142
142
|
if (!modelConfig.removeAll) {
|
|
143
143
|
chatModels = chatModels.filter((m) => !modelConfig.removed.includes(m.id));
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
//
|
|
146
|
+
// Asynchronously load configuration
|
|
147
147
|
const { LOBE_DEFAULT_MODEL_LIST } = await import('model-bank');
|
|
148
148
|
|
|
149
149
|
return produce(chatModels, (draft) => {
|
|
150
|
-
//
|
|
150
|
+
// Handle add or replace logic
|
|
151
151
|
for (const toAddModel of modelConfig.add) {
|
|
152
152
|
// first try to find the model in LOBE_DEFAULT_MODEL_LIST to confirm if it is a known model
|
|
153
153
|
let knownModel = LOBE_DEFAULT_MODEL_LIST.find(
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
* @param str
|
|
4
4
|
*/
|
|
5
5
|
export const sanitizeUTF8 = (str: string) => {
|
|
6
|
-
//
|
|
6
|
+
// Remove replacement character (0xFFFD) and other illegal characters
|
|
7
7
|
return (
|
|
8
8
|
str
|
|
9
|
-
.replaceAll('�', '') //
|
|
9
|
+
.replaceAll('�', '') // Remove Unicode replacement character
|
|
10
10
|
// eslint-disable-next-line no-control-regex
|
|
11
|
-
.replaceAll(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '') //
|
|
11
|
+
.replaceAll(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '') // Remove control characters
|
|
12
12
|
.replaceAll(/[\uD800-\uDFFF]/g, '')
|
|
13
|
-
); //
|
|
13
|
+
); // Remove unpaired surrogate code points
|
|
14
14
|
};
|
|
@@ -4,7 +4,7 @@ import { LobeChatPluginManifest, pluginManifestSchema } from '@lobehub/chat-plug
|
|
|
4
4
|
import { API_ENDPOINTS } from '@/services/_url';
|
|
5
5
|
|
|
6
6
|
const fetchJSON = async <T = any>(url: string, proxy = false): Promise<T> => {
|
|
7
|
-
// 2.
|
|
7
|
+
// 2. Send request
|
|
8
8
|
let res: Response;
|
|
9
9
|
try {
|
|
10
10
|
res = await (proxy ? fetch(API_ENDPOINTS.proxy, { body: url, method: 'POST' }) : fetch(url));
|
|
@@ -80,12 +80,12 @@ export const getToolManifest = async (
|
|
|
80
80
|
url?: string,
|
|
81
81
|
useProxy: boolean = false,
|
|
82
82
|
): Promise<LobeChatPluginManifest> => {
|
|
83
|
-
// 1.
|
|
83
|
+
// 1. Validate plugin
|
|
84
84
|
if (!url) {
|
|
85
85
|
throw new TypeError('noManifest');
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
// 2.
|
|
88
|
+
// 2. Send request
|
|
89
89
|
let data = await fetchJSON<LobeChatPluginManifest>(url, useProxy);
|
|
90
90
|
|
|
91
91
|
// @ts-ignore
|
|
@@ -94,7 +94,7 @@ export const getToolManifest = async (
|
|
|
94
94
|
if (data['description_for_model']) {
|
|
95
95
|
data = convertOpenAIManifestToLobeManifest(data as any);
|
|
96
96
|
}
|
|
97
|
-
// 3.
|
|
97
|
+
// 3. Validate plugin file format specification
|
|
98
98
|
const parser = pluginManifestSchema.safeParse(data);
|
|
99
99
|
|
|
100
100
|
if (!parser.success) {
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { LOBE_CHAT_TRACE_HEADER, LOBE_CHAT_TRACE_ID } from '@lobechat/const';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createTraceHeader, getTraceId, getTracePayload } from './trace';
|
|
5
|
+
|
|
6
|
+
describe('trace utilities', () => {
|
|
7
|
+
describe('getTracePayload', () => {
|
|
8
|
+
it('should extract and decode trace payload from request headers', () => {
|
|
9
|
+
const payload = {
|
|
10
|
+
traceId: '123-456-789',
|
|
11
|
+
sessionId: 'session-abc',
|
|
12
|
+
timestamp: 1234567890,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
16
|
+
const mockRequest = new Request('http://localhost', {
|
|
17
|
+
headers: {
|
|
18
|
+
[LOBE_CHAT_TRACE_HEADER]: encoded,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const result = getTracePayload(mockRequest);
|
|
23
|
+
|
|
24
|
+
expect(result).toEqual(payload);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return undefined when trace header is not present', () => {
|
|
28
|
+
const mockRequest = new Request('http://localhost');
|
|
29
|
+
|
|
30
|
+
const result = getTracePayload(mockRequest);
|
|
31
|
+
|
|
32
|
+
expect(result).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should handle empty trace header', () => {
|
|
36
|
+
const mockRequest = new Request('http://localhost', {
|
|
37
|
+
headers: {
|
|
38
|
+
[LOBE_CHAT_TRACE_HEADER]: '',
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const result = getTracePayload(mockRequest);
|
|
43
|
+
|
|
44
|
+
expect(result).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should decode complex payload with nested objects', () => {
|
|
48
|
+
const payload = {
|
|
49
|
+
traceId: 'trace-123',
|
|
50
|
+
metadata: {
|
|
51
|
+
user: { id: 'user-1', role: 'admin' },
|
|
52
|
+
context: { env: 'production', region: 'us-west-2' },
|
|
53
|
+
},
|
|
54
|
+
tags: ['important', 'production'],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
58
|
+
const mockRequest = new Request('http://localhost', {
|
|
59
|
+
headers: {
|
|
60
|
+
[LOBE_CHAT_TRACE_HEADER]: encoded,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const result = getTracePayload(mockRequest);
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual(payload);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should handle payload with special characters', () => {
|
|
70
|
+
const payload = {
|
|
71
|
+
traceId: 'trace-with-特殊字符-🔥',
|
|
72
|
+
message: 'Test with émojis 😀',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
76
|
+
const mockRequest = new Request('http://localhost', {
|
|
77
|
+
headers: {
|
|
78
|
+
[LOBE_CHAT_TRACE_HEADER]: encoded,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const result = getTracePayload(mockRequest);
|
|
83
|
+
|
|
84
|
+
expect(result).toEqual(payload);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should handle payload with null values', () => {
|
|
88
|
+
const payload = {
|
|
89
|
+
traceId: 'trace-123',
|
|
90
|
+
optionalField: null,
|
|
91
|
+
anotherField: undefined,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
95
|
+
const mockRequest = new Request('http://localhost', {
|
|
96
|
+
headers: {
|
|
97
|
+
[LOBE_CHAT_TRACE_HEADER]: encoded,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const result = getTracePayload(mockRequest);
|
|
102
|
+
|
|
103
|
+
// Note: undefined values are removed during JSON.stringify
|
|
104
|
+
expect(result).toEqual({
|
|
105
|
+
traceId: 'trace-123',
|
|
106
|
+
optionalField: null,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should handle numeric and boolean values in payload', () => {
|
|
111
|
+
const payload = {
|
|
112
|
+
traceId: 'trace-123',
|
|
113
|
+
count: 42,
|
|
114
|
+
isActive: true,
|
|
115
|
+
ratio: 3.14159,
|
|
116
|
+
isDisabled: false,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64');
|
|
120
|
+
const mockRequest = new Request('http://localhost', {
|
|
121
|
+
headers: {
|
|
122
|
+
[LOBE_CHAT_TRACE_HEADER]: encoded,
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const result = getTracePayload(mockRequest);
|
|
127
|
+
|
|
128
|
+
expect(result).toEqual(payload);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('getTraceId', () => {
|
|
133
|
+
it('should extract trace ID from response headers', () => {
|
|
134
|
+
const traceId = 'trace-xyz-789';
|
|
135
|
+
const mockResponse = new Response(null, {
|
|
136
|
+
headers: {
|
|
137
|
+
[LOBE_CHAT_TRACE_ID]: traceId,
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const result = getTraceId(mockResponse);
|
|
142
|
+
|
|
143
|
+
expect(result).toBe(traceId);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should return null when trace ID header is not present', () => {
|
|
147
|
+
const mockResponse = new Response(null);
|
|
148
|
+
|
|
149
|
+
const result = getTraceId(mockResponse);
|
|
150
|
+
|
|
151
|
+
expect(result).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should handle empty trace ID', () => {
|
|
155
|
+
const mockResponse = new Response(null, {
|
|
156
|
+
headers: {
|
|
157
|
+
[LOBE_CHAT_TRACE_ID]: '',
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const result = getTraceId(mockResponse);
|
|
162
|
+
|
|
163
|
+
expect(result).toBe('');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should handle trace ID with special characters', () => {
|
|
167
|
+
const traceId = 'trace-123-abc-特殊-🔥';
|
|
168
|
+
const mockResponse = new Response(null, {
|
|
169
|
+
headers: {
|
|
170
|
+
[LOBE_CHAT_TRACE_ID]: traceId,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const result = getTraceId(mockResponse);
|
|
175
|
+
|
|
176
|
+
expect(result).toBe(traceId);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('should handle UUID-formatted trace IDs', () => {
|
|
180
|
+
const traceId = '550e8400-e29b-41d4-a716-446655440000';
|
|
181
|
+
const mockResponse = new Response(null, {
|
|
182
|
+
headers: {
|
|
183
|
+
[LOBE_CHAT_TRACE_ID]: traceId,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const result = getTraceId(mockResponse);
|
|
188
|
+
|
|
189
|
+
expect(result).toBe(traceId);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('createTraceHeader', () => {
|
|
194
|
+
it('should create a base64-encoded trace header from payload', () => {
|
|
195
|
+
const payload = {
|
|
196
|
+
traceId: 'trace-123',
|
|
197
|
+
sessionId: 'session-456',
|
|
198
|
+
timestamp: 1234567890,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const result = createTraceHeader(payload);
|
|
202
|
+
|
|
203
|
+
expect(result).toHaveProperty(LOBE_CHAT_TRACE_HEADER);
|
|
204
|
+
expect(typeof result[LOBE_CHAT_TRACE_HEADER]).toBe('string');
|
|
205
|
+
|
|
206
|
+
// Verify it's valid base64
|
|
207
|
+
const decoded = Buffer.from(result[LOBE_CHAT_TRACE_HEADER], 'base64').toString('utf8');
|
|
208
|
+
expect(JSON.parse(decoded)).toEqual(payload);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('should create header for empty payload object', () => {
|
|
212
|
+
const payload = {};
|
|
213
|
+
|
|
214
|
+
const result = createTraceHeader(payload);
|
|
215
|
+
|
|
216
|
+
expect(result).toHaveProperty(LOBE_CHAT_TRACE_HEADER);
|
|
217
|
+
|
|
218
|
+
const decoded = Buffer.from(result[LOBE_CHAT_TRACE_HEADER], 'base64').toString('utf8');
|
|
219
|
+
expect(JSON.parse(decoded)).toEqual({});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should handle payload with nested objects', () => {
|
|
223
|
+
const payload = {
|
|
224
|
+
traceId: 'trace-123',
|
|
225
|
+
metadata: {
|
|
226
|
+
user: { id: 'user-1', name: 'John' },
|
|
227
|
+
context: { env: 'prod' },
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const result = createTraceHeader(payload);
|
|
232
|
+
|
|
233
|
+
const decoded = Buffer.from(result[LOBE_CHAT_TRACE_HEADER], 'base64').toString('utf8');
|
|
234
|
+
expect(JSON.parse(decoded)).toEqual(payload);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should handle payload with arrays', () => {
|
|
238
|
+
const payload = {
|
|
239
|
+
traceId: 'trace-123',
|
|
240
|
+
tags: ['tag1', 'tag2', 'tag3'],
|
|
241
|
+
values: [1, 2, 3, 4, 5],
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const result = createTraceHeader(payload);
|
|
245
|
+
|
|
246
|
+
const decoded = Buffer.from(result[LOBE_CHAT_TRACE_HEADER], 'base64').toString('utf8');
|
|
247
|
+
expect(JSON.parse(decoded)).toEqual(payload);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should handle payload with Unicode characters', () => {
|
|
251
|
+
const payload = {
|
|
252
|
+
traceId: 'trace-特殊-🔥',
|
|
253
|
+
message: 'Hello 世界 😀',
|
|
254
|
+
description: 'Тест тест',
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const result = createTraceHeader(payload);
|
|
258
|
+
|
|
259
|
+
const decoded = Buffer.from(result[LOBE_CHAT_TRACE_HEADER], 'base64').toString('utf8');
|
|
260
|
+
expect(JSON.parse(decoded)).toEqual(payload);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should handle payload with null values', () => {
|
|
264
|
+
const payload = {
|
|
265
|
+
traceId: 'trace-123',
|
|
266
|
+
optionalField: null,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const result = createTraceHeader(payload);
|
|
270
|
+
|
|
271
|
+
const decoded = Buffer.from(result[LOBE_CHAT_TRACE_HEADER], 'base64').toString('utf8');
|
|
272
|
+
expect(JSON.parse(decoded)).toEqual(payload);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should handle payload with boolean and numeric values', () => {
|
|
276
|
+
const payload = {
|
|
277
|
+
traceId: 'trace-123',
|
|
278
|
+
count: 42,
|
|
279
|
+
isActive: true,
|
|
280
|
+
ratio: 3.14,
|
|
281
|
+
isDisabled: false,
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const result = createTraceHeader(payload);
|
|
285
|
+
|
|
286
|
+
const decoded = Buffer.from(result[LOBE_CHAT_TRACE_HEADER], 'base64').toString('utf8');
|
|
287
|
+
expect(JSON.parse(decoded)).toEqual(payload);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should create header that works with getTracePayload', () => {
|
|
291
|
+
const payload = {
|
|
292
|
+
traceId: 'trace-round-trip',
|
|
293
|
+
sessionId: 'session-test',
|
|
294
|
+
timestamp: Date.now(),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const header = createTraceHeader(payload);
|
|
298
|
+
const mockRequest = new Request('http://localhost', {
|
|
299
|
+
headers: header,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const extractedPayload = getTracePayload(mockRequest);
|
|
303
|
+
|
|
304
|
+
expect(extractedPayload).toEqual(payload);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('round-trip encoding and decoding', () => {
|
|
309
|
+
it('should correctly encode and decode simple payload', () => {
|
|
310
|
+
const originalPayload = {
|
|
311
|
+
traceId: 'test-123',
|
|
312
|
+
data: 'test-data',
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const header = createTraceHeader(originalPayload);
|
|
316
|
+
const mockRequest = new Request('http://localhost', { headers: header });
|
|
317
|
+
const decodedPayload = getTracePayload(mockRequest);
|
|
318
|
+
|
|
319
|
+
expect(decodedPayload).toEqual(originalPayload);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should correctly encode and decode complex payload', () => {
|
|
323
|
+
const originalPayload = {
|
|
324
|
+
traceId: 'complex-trace',
|
|
325
|
+
metadata: {
|
|
326
|
+
user: { id: 'usr-123', roles: ['admin', 'user'] },
|
|
327
|
+
timestamps: { created: 1234567890, updated: 1234567900 },
|
|
328
|
+
},
|
|
329
|
+
tags: ['production', 'critical'],
|
|
330
|
+
metrics: { duration: 123.45, count: 10 },
|
|
331
|
+
flags: { enabled: true, debug: false },
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const header = createTraceHeader(originalPayload);
|
|
335
|
+
const mockRequest = new Request('http://localhost', { headers: header });
|
|
336
|
+
const decodedPayload = getTracePayload(mockRequest);
|
|
337
|
+
|
|
338
|
+
expect(decodedPayload).toEqual(originalPayload);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should preserve data types through encoding and decoding', () => {
|
|
342
|
+
const originalPayload = {
|
|
343
|
+
traceId: 'trace-types-test',
|
|
344
|
+
sessionId: 'session-123',
|
|
345
|
+
userId: 'user-456',
|
|
346
|
+
topicId: 'topic-789',
|
|
347
|
+
observationId: 'obs-abc',
|
|
348
|
+
enabled: true,
|
|
349
|
+
tags: ['tag1', 'tag2', 'tag3'],
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const header = createTraceHeader(originalPayload);
|
|
353
|
+
const mockRequest = new Request('http://localhost', { headers: header });
|
|
354
|
+
const decodedPayload = getTracePayload(mockRequest);
|
|
355
|
+
|
|
356
|
+
expect(decodedPayload).toEqual(originalPayload);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
});
|
|
@@ -5,20 +5,20 @@ interface UriParserResult {
|
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export const parseDataUri = (dataUri: string): UriParserResult => {
|
|
8
|
-
//
|
|
8
|
+
// Regular expression to match the entire Data URI structure
|
|
9
9
|
const dataUriMatch = dataUri.match(/^data:([^;]+);base64,(.+)$/);
|
|
10
10
|
|
|
11
11
|
if (dataUriMatch) {
|
|
12
|
-
//
|
|
12
|
+
// If it's a valid Data URI
|
|
13
13
|
return { base64: dataUriMatch[2], mimeType: dataUriMatch[1], type: 'base64' };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
try {
|
|
17
17
|
new URL(dataUri);
|
|
18
|
-
//
|
|
18
|
+
// If it's a valid URL
|
|
19
19
|
return { base64: null, mimeType: null, type: 'url' };
|
|
20
20
|
} catch {
|
|
21
|
-
//
|
|
21
|
+
// Neither a Data URI nor a valid URL
|
|
22
22
|
return { base64: null, mimeType: null, type: null };
|
|
23
23
|
}
|
|
24
24
|
};
|