@lobehub/chat 1.99.5 → 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.
@@ -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
- ...imageList.map(
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
- ...m.imageList.map(
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 = messages.map((m): OpenAIChatMessage => {
574
- const supportTools = isCanUseFC(model, provider);
575
- switch (m.role) {
576
- case 'user': {
577
- return { content: getUserContent(m), role: m.role };
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
- return {
588
- content,
589
- role: m.role,
590
- tool_calls: m.tools?.map(
591
- (tool): MessageToolCall => ({
592
- function: {
593
- arguments: tool.arguments,
594
- name: genToolCallingName(tool.identifier, tool.apiName, tool.type),
595
- },
596
- id: tool.id,
597
- type: 'function',
598
- }),
599
- ),
600
- };
601
- }
602
-
603
- case 'tool': {
604
- if (!supportTools) {
605
- return { content: m.content, role: 'user' };
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
- return {
609
- content: m.content,
610
- name: genToolCallingName(m.plugin!.identifier, m.plugin!.apiName, m.plugin?.type),
611
- role: m.role,
612
- tool_call_id: m.tool_call_id,
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
- default: {
617
- return { content: m.content, role: m.role as any };
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
 
@@ -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
+ }