@lobehub/chat 1.99.4 → 1.99.6
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/.cursor/rules/code-review.mdc +38 -34
- package/.cursor/rules/system-role.mdc +8 -3
- package/.cursor/rules/testing-guide/testing-guide.mdc +155 -233
- package/CHANGELOG.md +50 -0
- package/apps/desktop/package.json +1 -1
- package/changelog/v1.json +18 -0
- package/package.json +1 -1
- package/src/services/__tests__/chat.test.ts +998 -62
- package/src/services/chat.ts +104 -58
- package/src/tools/web-browsing/Render/PageContent/index.tsx +1 -1
- package/src/utils/url.test.ts +42 -1
- package/src/utils/url.ts +28 -0
package/src/services/chat.ts
CHANGED
@@ -15,6 +15,7 @@ import {
|
|
15
15
|
ChatCompletionErrorPayload,
|
16
16
|
ModelProvider,
|
17
17
|
} from '@/libs/model-runtime';
|
18
|
+
import { parseDataUri } from '@/libs/model-runtime/utils/uriParser';
|
18
19
|
import { filesPrompts } from '@/prompts/files';
|
19
20
|
import { BuiltinSystemRolePrompts } from '@/prompts/systemRole';
|
20
21
|
import { getAgentStoreState } from '@/store/agent';
|
@@ -35,7 +36,7 @@ import {
|
|
35
36
|
import { WebBrowsingManifest } from '@/tools/web-browsing';
|
36
37
|
import { WorkingModel } from '@/types/agent';
|
37
38
|
import { ChatErrorType } from '@/types/fetch';
|
38
|
-
import { ChatMessage, MessageToolCall } from '@/types/message';
|
39
|
+
import { ChatImageItem, ChatMessage, MessageToolCall } from '@/types/message';
|
39
40
|
import type { ChatStreamPayload, OpenAIChatMessage } from '@/types/openai/chat';
|
40
41
|
import { UserMessageContentPart } from '@/types/openai/chat';
|
41
42
|
import { parsePlaceholderVariablesMessages } from '@/utils/client/parserPlaceholder';
|
@@ -46,8 +47,10 @@ import {
|
|
46
47
|
getMessageError,
|
47
48
|
standardizeAnimationStyle,
|
48
49
|
} from '@/utils/fetch';
|
50
|
+
import { imageUrlToBase64 } from '@/utils/imageToBase64';
|
49
51
|
import { genToolCallingName } from '@/utils/toolCall';
|
50
52
|
import { createTraceHeader, getTraceId } from '@/utils/trace';
|
53
|
+
import { isLocalUrl } from '@/utils/url';
|
51
54
|
|
52
55
|
import { createHeaderWithAuth, createPayloadWithKeyVaults } from './_auth';
|
53
56
|
import { API_ENDPOINTS } from './_url';
|
@@ -61,6 +64,14 @@ const isCanUseFC = (model: string, provider: string) => {
|
|
61
64
|
return aiModelSelectors.isModelSupportToolUse(model, provider)(getAiInfraStoreState());
|
62
65
|
};
|
63
66
|
|
67
|
+
const isCanUseVision = (model: string, provider: string) => {
|
68
|
+
// TODO: remove isDeprecatedEdition condition in V2.0
|
69
|
+
if (isDeprecatedEdition) {
|
70
|
+
return modelProviderSelectors.isModelEnabledVision(model)(getUserStoreState());
|
71
|
+
}
|
72
|
+
return aiModelSelectors.isModelSupportVision(model, provider)(getAiInfraStoreState());
|
73
|
+
};
|
74
|
+
|
64
75
|
/**
|
65
76
|
* TODO: we need to update this function to auto find deploymentName with provider setting config
|
66
77
|
*/
|
@@ -205,7 +216,7 @@ class ChatService {
|
|
205
216
|
|
206
217
|
// ============ 2. preprocess messages ============ //
|
207
218
|
|
208
|
-
const oaiMessages = this.processMessages(
|
219
|
+
const oaiMessages = await this.processMessages(
|
209
220
|
{
|
210
221
|
messages: parsedMessages,
|
211
222
|
model: payload.model,
|
@@ -475,7 +486,7 @@ class ChatService {
|
|
475
486
|
onLoadingChange?.(true);
|
476
487
|
|
477
488
|
try {
|
478
|
-
const oaiMessages = this.processMessages({
|
489
|
+
const oaiMessages = await this.processMessages({
|
479
490
|
messages: params.messages as any,
|
480
491
|
model: params.model!,
|
481
492
|
provider: params.provider!,
|
@@ -507,7 +518,7 @@ class ChatService {
|
|
507
518
|
}
|
508
519
|
};
|
509
520
|
|
510
|
-
private processMessages = (
|
521
|
+
private processMessages = async (
|
511
522
|
{
|
512
523
|
messages = [],
|
513
524
|
tools,
|
@@ -520,29 +531,28 @@ class ChatService {
|
|
520
531
|
tools?: string[];
|
521
532
|
},
|
522
533
|
options?: FetchOptions,
|
523
|
-
): OpenAIChatMessage[] => {
|
534
|
+
): Promise<OpenAIChatMessage[]> => {
|
524
535
|
// handle content type for vision model
|
525
536
|
// for the models with visual ability, add image url to content
|
526
537
|
// refs: https://platform.openai.com/docs/guides/vision/quick-start
|
527
|
-
const getUserContent = (m: ChatMessage) => {
|
538
|
+
const getUserContent = async (m: ChatMessage) => {
|
528
539
|
// only if message doesn't have images and files, then return the plain content
|
529
540
|
if ((!m.imageList || m.imageList.length === 0) && (!m.fileList || m.fileList.length === 0))
|
530
541
|
return m.content;
|
531
542
|
|
532
543
|
const imageList = m.imageList || [];
|
544
|
+
const imageContentParts = await this.processImageList({ imageList, model, provider });
|
533
545
|
|
534
546
|
const filesContext = isServerMode
|
535
547
|
? filesPrompts({ addUrl: !isDesktop, fileList: m.fileList, imageList })
|
536
548
|
: '';
|
537
549
|
return [
|
538
550
|
{ text: (m.content + '\n\n' + filesContext).trim(), type: 'text' },
|
539
|
-
...
|
540
|
-
(i) => ({ image_url: { detail: 'auto', url: i.url }, type: 'image_url' }) as const,
|
541
|
-
),
|
551
|
+
...imageContentParts,
|
542
552
|
] as UserMessageContentPart[];
|
543
553
|
};
|
544
554
|
|
545
|
-
const getAssistantContent = (m: ChatMessage) => {
|
555
|
+
const getAssistantContent = async (m: ChatMessage) => {
|
546
556
|
// signature is a signal of anthropic thinking mode
|
547
557
|
const shouldIncludeThinking = m.reasoning && !!m.reasoning?.signature;
|
548
558
|
|
@@ -559,65 +569,70 @@ class ChatService {
|
|
559
569
|
// only if message doesn't have images and files, then return the plain content
|
560
570
|
|
561
571
|
if (m.imageList && m.imageList.length > 0) {
|
572
|
+
const imageContentParts = await this.processImageList({
|
573
|
+
imageList: m.imageList,
|
574
|
+
model,
|
575
|
+
provider,
|
576
|
+
});
|
562
577
|
return [
|
563
578
|
!!m.content ? { text: m.content, type: 'text' } : undefined,
|
564
|
-
...
|
565
|
-
(i) => ({ image_url: { detail: 'auto', url: i.url }, type: 'image_url' }) as const,
|
566
|
-
),
|
579
|
+
...imageContentParts,
|
567
580
|
].filter(Boolean) as UserMessageContentPart[];
|
568
581
|
}
|
569
582
|
|
570
583
|
return m.content;
|
571
584
|
};
|
572
585
|
|
573
|
-
let postMessages =
|
574
|
-
|
575
|
-
|
576
|
-
|
577
|
-
|
578
|
-
|
579
|
-
|
580
|
-
case 'assistant': {
|
581
|
-
const content = getAssistantContent(m);
|
582
|
-
|
583
|
-
if (!supportTools) {
|
584
|
-
return { content, role: m.role };
|
586
|
+
let postMessages = await Promise.all(
|
587
|
+
messages.map(async (m): Promise<OpenAIChatMessage> => {
|
588
|
+
const supportTools = isCanUseFC(model, provider);
|
589
|
+
switch (m.role) {
|
590
|
+
case 'user': {
|
591
|
+
return { content: await getUserContent(m), role: m.role };
|
585
592
|
}
|
586
593
|
|
587
|
-
|
588
|
-
content
|
589
|
-
|
590
|
-
|
591
|
-
|
592
|
-
|
593
|
-
|
594
|
-
|
595
|
-
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
594
|
+
case 'assistant': {
|
595
|
+
const content = await getAssistantContent(m);
|
596
|
+
|
597
|
+
if (!supportTools) {
|
598
|
+
return { content, role: m.role };
|
599
|
+
}
|
600
|
+
|
601
|
+
return {
|
602
|
+
content,
|
603
|
+
role: m.role,
|
604
|
+
tool_calls: m.tools?.map(
|
605
|
+
(tool): MessageToolCall => ({
|
606
|
+
function: {
|
607
|
+
arguments: tool.arguments,
|
608
|
+
name: genToolCallingName(tool.identifier, tool.apiName, tool.type),
|
609
|
+
},
|
610
|
+
id: tool.id,
|
611
|
+
type: 'function',
|
612
|
+
}),
|
613
|
+
),
|
614
|
+
};
|
606
615
|
}
|
607
616
|
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
617
|
+
case 'tool': {
|
618
|
+
if (!supportTools) {
|
619
|
+
return { content: m.content, role: 'user' };
|
620
|
+
}
|
621
|
+
|
622
|
+
return {
|
623
|
+
content: m.content,
|
624
|
+
name: genToolCallingName(m.plugin!.identifier, m.plugin!.apiName, m.plugin?.type),
|
625
|
+
role: m.role,
|
626
|
+
tool_call_id: m.tool_call_id,
|
627
|
+
};
|
628
|
+
}
|
615
629
|
|
616
|
-
|
617
|
-
|
630
|
+
default: {
|
631
|
+
return { content: m.content, role: m.role as any };
|
632
|
+
}
|
618
633
|
}
|
619
|
-
}
|
620
|
-
|
634
|
+
}),
|
635
|
+
);
|
621
636
|
|
622
637
|
postMessages = produce(postMessages, (draft) => {
|
623
638
|
// if it's a welcome question, inject InboxGuide SystemRole
|
@@ -657,6 +672,37 @@ class ChatService {
|
|
657
672
|
return this.reorderToolMessages(postMessages);
|
658
673
|
};
|
659
674
|
|
675
|
+
/**
|
676
|
+
* Process imageList: convert local URLs to base64 and format as UserMessageContentPart
|
677
|
+
*/
|
678
|
+
private processImageList = async ({
|
679
|
+
model,
|
680
|
+
provider,
|
681
|
+
imageList,
|
682
|
+
}: {
|
683
|
+
imageList: ChatImageItem[];
|
684
|
+
model: string;
|
685
|
+
provider: string;
|
686
|
+
}) => {
|
687
|
+
if (!isCanUseVision(model, provider)) {
|
688
|
+
return [];
|
689
|
+
}
|
690
|
+
|
691
|
+
return Promise.all(
|
692
|
+
imageList.map(async (image) => {
|
693
|
+
const { type } = parseDataUri(image.url);
|
694
|
+
|
695
|
+
let processedUrl = image.url;
|
696
|
+
if (type === 'url' && isLocalUrl(image.url)) {
|
697
|
+
const { base64, mimeType } = await imageUrlToBase64(image.url);
|
698
|
+
processedUrl = `data:${mimeType};base64,${base64}`;
|
699
|
+
}
|
700
|
+
|
701
|
+
return { image_url: { detail: 'auto', url: processedUrl }, type: 'image_url' } as const;
|
702
|
+
}),
|
703
|
+
);
|
704
|
+
};
|
705
|
+
|
660
706
|
private mapTrace = (trace?: TracePayload, tag?: TraceTagMap): TracePayload => {
|
661
707
|
const tags = sessionMetaSelectors.currentAgentMeta(getSessionStoreState()).tags || [];
|
662
708
|
|
@@ -681,9 +727,6 @@ class ChatService {
|
|
681
727
|
provider: string;
|
682
728
|
signal?: AbortSignal;
|
683
729
|
}) => {
|
684
|
-
const agentRuntime = await initializeWithClientStore(params.provider, params.payload);
|
685
|
-
const data = params.payload as ChatStreamPayload;
|
686
|
-
|
687
730
|
/**
|
688
731
|
* if enable login and not signed in, return unauthorized error
|
689
732
|
*/
|
@@ -692,6 +735,9 @@ class ChatService {
|
|
692
735
|
throw AgentRuntimeError.createError(ChatErrorType.InvalidAccessCode);
|
693
736
|
}
|
694
737
|
|
738
|
+
const agentRuntime = await initializeWithClientStore(params.provider, params.payload);
|
739
|
+
const data = params.payload as ChatStreamPayload;
|
740
|
+
|
695
741
|
return agentRuntime.chat(data, { signal: params.signal });
|
696
742
|
};
|
697
743
|
|
@@ -17,7 +17,7 @@ const PagesContent = memo<PagesContentProps>(({ results, messageId, urls }) => {
|
|
17
17
|
if (!results || results.length === 0) {
|
18
18
|
return (
|
19
19
|
<Flexbox gap={12} horizontal>
|
20
|
-
{urls.map((url, index) => (
|
20
|
+
{urls && urls.length > 0 && urls.map((url, index) => (
|
21
21
|
<Loading key={`${url}_${index}`} url={url} />
|
22
22
|
))}
|
23
23
|
</Flexbox>
|
package/src/utils/url.test.ts
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
import { vi } from 'vitest';
|
2
2
|
|
3
3
|
import { pathString } from './url';
|
4
|
-
import { inferContentTypeFromImageUrl, inferFileExtensionFromImageUrl } from './url';
|
4
|
+
import { inferContentTypeFromImageUrl, inferFileExtensionFromImageUrl, isLocalUrl } from './url';
|
5
5
|
|
6
6
|
describe('pathString', () => {
|
7
7
|
it('should handle basic path', () => {
|
@@ -398,3 +398,44 @@ describe('inferFileExtensionFromImageUrl', () => {
|
|
398
398
|
expect(result).toBe('gif');
|
399
399
|
});
|
400
400
|
});
|
401
|
+
|
402
|
+
describe('isLocalUrl', () => {
|
403
|
+
it('should return true for URLs with 127.0.0.1 as hostname', () => {
|
404
|
+
expect(isLocalUrl('http://127.0.0.1')).toBe(true);
|
405
|
+
expect(isLocalUrl('https://127.0.0.1')).toBe(true);
|
406
|
+
expect(isLocalUrl('http://127.0.0.1:8080')).toBe(true);
|
407
|
+
expect(isLocalUrl('http://127.0.0.1/path/to/resource')).toBe(true);
|
408
|
+
expect(isLocalUrl('https://127.0.0.1/path?query=1#hash')).toBe(true);
|
409
|
+
});
|
410
|
+
|
411
|
+
it('should return false for URLs with "localhost" as hostname', () => {
|
412
|
+
expect(isLocalUrl('http://localhost')).toBe(false);
|
413
|
+
expect(isLocalUrl('http://localhost:3000')).toBe(false);
|
414
|
+
});
|
415
|
+
|
416
|
+
it('should return false for other IP addresses', () => {
|
417
|
+
expect(isLocalUrl('http://192.168.1.1')).toBe(false);
|
418
|
+
expect(isLocalUrl('http://0.0.0.0')).toBe(false);
|
419
|
+
expect(isLocalUrl('http://127.0.0.2')).toBe(false);
|
420
|
+
});
|
421
|
+
|
422
|
+
it('should return false for domain names', () => {
|
423
|
+
expect(isLocalUrl('https://example.com')).toBe(false);
|
424
|
+
expect(isLocalUrl('http://www.google.com')).toBe(false);
|
425
|
+
});
|
426
|
+
|
427
|
+
it('should return false for malformed URLs', () => {
|
428
|
+
expect(isLocalUrl('invalid-url')).toBe(false);
|
429
|
+
expect(isLocalUrl('http://')).toBe(false);
|
430
|
+
expect(isLocalUrl('a string but not a url')).toBe(false);
|
431
|
+
});
|
432
|
+
|
433
|
+
it('should return false for empty or nullish strings', () => {
|
434
|
+
expect(isLocalUrl('')).toBe(false);
|
435
|
+
});
|
436
|
+
|
437
|
+
it('should return false for relative URLs', () => {
|
438
|
+
expect(isLocalUrl('/path/to/file')).toBe(false);
|
439
|
+
expect(isLocalUrl('./relative/path')).toBe(false);
|
440
|
+
});
|
441
|
+
});
|
package/src/utils/url.ts
CHANGED
@@ -123,3 +123,31 @@ export function inferContentTypeFromImageUrl(url: string) {
|
|
123
123
|
|
124
124
|
return mimeType!; // Non-null assertion is safe due to whitelist validation
|
125
125
|
}
|
126
|
+
|
127
|
+
/**
|
128
|
+
* Check if a URL points to localhost (127.0.0.1)
|
129
|
+
*
|
130
|
+
* This function safely determines if the provided URL's hostname is '127.0.0.1'.
|
131
|
+
* It handles malformed URLs gracefully by returning false instead of throwing errors.
|
132
|
+
*
|
133
|
+
* @param url - The URL string to check
|
134
|
+
* @returns true if the URL's hostname is '127.0.0.1', false otherwise (including for malformed URLs)
|
135
|
+
*
|
136
|
+
* @example
|
137
|
+
* ```typescript
|
138
|
+
* isLocalUrl('http://127.0.0.1:8080/path') // true
|
139
|
+
* isLocalUrl('https://example.com') // false
|
140
|
+
* isLocalUrl('invalid-url') // false (instead of throwing)
|
141
|
+
* isLocalUrl('') // false (instead of throwing)
|
142
|
+
* ```
|
143
|
+
*
|
144
|
+
* check: apps/desktop/src/main/core/StaticFileServerManager.ts
|
145
|
+
*/
|
146
|
+
export function isLocalUrl(url: string) {
|
147
|
+
try {
|
148
|
+
return new URL(url).hostname === '127.0.0.1';
|
149
|
+
} catch {
|
150
|
+
// Return false for malformed URLs instead of throwing
|
151
|
+
return false;
|
152
|
+
}
|
153
|
+
}
|