@lobehub/chat 1.99.5 → 1.100.0

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.
Files changed (98) hide show
  1. package/.cursor/rules/code-review.mdc +38 -34
  2. package/.cursor/rules/system-role.mdc +8 -3
  3. package/.cursor/rules/testing-guide/testing-guide.mdc +155 -233
  4. package/.github/workflows/desktop-pr-build.yml +3 -3
  5. package/.github/workflows/release-desktop-beta.yml +3 -3
  6. package/CHANGELOG.md +50 -0
  7. package/apps/desktop/package.json +6 -3
  8. package/apps/desktop/src/main/controllers/AuthCtr.ts +310 -111
  9. package/apps/desktop/src/main/controllers/NetworkProxyCtr.ts +1 -1
  10. package/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts +50 -3
  11. package/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts +188 -23
  12. package/apps/desktop/src/main/controllers/__tests__/NetworkProxyCtr.test.ts +37 -18
  13. package/apps/desktop/src/main/types/store.ts +1 -0
  14. package/apps/desktop/src/preload/electronApi.ts +2 -1
  15. package/apps/desktop/src/preload/streamer.ts +58 -0
  16. package/changelog/v1.json +18 -0
  17. package/docs/development/database-schema.dbml +9 -0
  18. package/locales/ar/electron.json +3 -0
  19. package/locales/ar/oauth.json +8 -4
  20. package/locales/bg-BG/electron.json +3 -0
  21. package/locales/bg-BG/oauth.json +8 -4
  22. package/locales/de-DE/electron.json +3 -0
  23. package/locales/de-DE/oauth.json +9 -5
  24. package/locales/en-US/electron.json +3 -0
  25. package/locales/en-US/oauth.json +8 -4
  26. package/locales/es-ES/electron.json +3 -0
  27. package/locales/es-ES/oauth.json +9 -5
  28. package/locales/fa-IR/electron.json +3 -0
  29. package/locales/fa-IR/oauth.json +8 -4
  30. package/locales/fr-FR/electron.json +3 -0
  31. package/locales/fr-FR/oauth.json +8 -4
  32. package/locales/it-IT/electron.json +3 -0
  33. package/locales/it-IT/oauth.json +9 -5
  34. package/locales/ja-JP/electron.json +3 -0
  35. package/locales/ja-JP/oauth.json +8 -4
  36. package/locales/ko-KR/electron.json +3 -0
  37. package/locales/ko-KR/oauth.json +8 -4
  38. package/locales/nl-NL/electron.json +3 -0
  39. package/locales/nl-NL/oauth.json +9 -5
  40. package/locales/pl-PL/electron.json +3 -0
  41. package/locales/pl-PL/oauth.json +8 -4
  42. package/locales/pt-BR/electron.json +3 -0
  43. package/locales/pt-BR/oauth.json +8 -4
  44. package/locales/ru-RU/electron.json +3 -0
  45. package/locales/ru-RU/oauth.json +8 -4
  46. package/locales/tr-TR/electron.json +3 -0
  47. package/locales/tr-TR/oauth.json +8 -4
  48. package/locales/vi-VN/electron.json +3 -0
  49. package/locales/vi-VN/oauth.json +9 -5
  50. package/locales/zh-CN/electron.json +3 -0
  51. package/locales/zh-CN/oauth.json +8 -4
  52. package/locales/zh-TW/electron.json +3 -0
  53. package/locales/zh-TW/oauth.json +8 -4
  54. package/package.json +3 -3
  55. package/packages/electron-client-ipc/src/dispatch.ts +14 -2
  56. package/packages/electron-client-ipc/src/index.ts +1 -0
  57. package/packages/electron-client-ipc/src/streamInvoke.ts +62 -0
  58. package/packages/electron-client-ipc/src/types/proxyTRPCRequest.ts +5 -0
  59. package/packages/electron-client-ipc/src/utils/headers.ts +27 -0
  60. package/packages/electron-client-ipc/src/utils/request.ts +28 -0
  61. package/src/app/(backend)/oidc/callback/desktop/route.ts +58 -0
  62. package/src/app/(backend)/oidc/handoff/route.ts +46 -0
  63. package/src/app/[variants]/oauth/callback/error/page.tsx +55 -0
  64. package/src/app/[variants]/oauth/callback/layout.tsx +12 -0
  65. package/src/app/[variants]/oauth/callback/loading.tsx +3 -0
  66. package/src/app/[variants]/oauth/{consent/[uid] → callback}/success/page.tsx +10 -1
  67. package/src/app/[variants]/oauth/consent/[uid]/Consent.tsx +7 -1
  68. package/src/database/client/migrations.json +8 -0
  69. package/src/database/migrations/0028_oauth_handoffs.sql +8 -0
  70. package/src/database/migrations/meta/0028_snapshot.json +6055 -0
  71. package/src/database/migrations/meta/_journal.json +7 -0
  72. package/src/database/models/oauthHandoff.ts +94 -0
  73. package/src/database/repositories/tableViewer/index.test.ts +1 -1
  74. package/src/database/schemas/oidc.ts +46 -0
  75. package/src/features/ElectronTitlebar/Connection/Waiting.tsx +59 -115
  76. package/src/features/ElectronTitlebar/Connection/WaitingAnim.tsx +114 -0
  77. package/src/libs/oidc-provider/config.ts +16 -17
  78. package/src/libs/oidc-provider/jwt.ts +135 -0
  79. package/src/libs/oidc-provider/provider.ts +22 -38
  80. package/src/libs/trpc/client/async.ts +1 -2
  81. package/src/libs/trpc/client/edge.ts +1 -2
  82. package/src/libs/trpc/client/lambda.ts +1 -1
  83. package/src/libs/trpc/client/tools.ts +1 -2
  84. package/src/libs/trpc/lambda/context.ts +9 -16
  85. package/src/locales/default/electron.ts +3 -0
  86. package/src/locales/default/oauth.ts +8 -4
  87. package/src/middleware.ts +10 -4
  88. package/src/server/services/oidc/index.ts +0 -71
  89. package/src/services/__tests__/chat.test.ts +998 -62
  90. package/src/services/chat.ts +109 -59
  91. package/src/services/electron/remoteServer.ts +0 -7
  92. package/src/{libs/trpc/client/helpers → utils/electron}/desktopRemoteRPCFetch.ts +22 -7
  93. package/src/utils/server/auth.ts +22 -0
  94. package/src/utils/url.test.ts +42 -1
  95. package/src/utils/url.ts +28 -0
  96. package/src/app/[variants]/oauth/consent/[uid]/failed/page.tsx +0 -36
  97. package/src/app/[variants]/oauth/handoff/Client.tsx +0 -98
  98. package/src/app/[variants]/oauth/handoff/page.tsx +0 -13
@@ -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,10 +36,11 @@ 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';
43
+ import { fetchWithInvokeStream } from '@/utils/electron/desktopRemoteRPCFetch';
42
44
  import { createErrorResponse } from '@/utils/errorResponse';
43
45
  import {
44
46
  FetchSSEOptions,
@@ -46,8 +48,10 @@ import {
46
48
  getMessageError,
47
49
  standardizeAnimationStyle,
48
50
  } from '@/utils/fetch';
51
+ import { imageUrlToBase64 } from '@/utils/imageToBase64';
49
52
  import { genToolCallingName } from '@/utils/toolCall';
50
53
  import { createTraceHeader, getTraceId } from '@/utils/trace';
54
+ import { isLocalUrl } from '@/utils/url';
51
55
 
52
56
  import { createHeaderWithAuth, createPayloadWithKeyVaults } from './_auth';
53
57
  import { API_ENDPOINTS } from './_url';
@@ -61,6 +65,14 @@ const isCanUseFC = (model: string, provider: string) => {
61
65
  return aiModelSelectors.isModelSupportToolUse(model, provider)(getAiInfraStoreState());
62
66
  };
63
67
 
68
+ const isCanUseVision = (model: string, provider: string) => {
69
+ // TODO: remove isDeprecatedEdition condition in V2.0
70
+ if (isDeprecatedEdition) {
71
+ return modelProviderSelectors.isModelEnabledVision(model)(getUserStoreState());
72
+ }
73
+ return aiModelSelectors.isModelSupportVision(model, provider)(getAiInfraStoreState());
74
+ };
75
+
64
76
  /**
65
77
  * TODO: we need to update this function to auto find deploymentName with provider setting config
66
78
  */
@@ -205,7 +217,7 @@ class ChatService {
205
217
 
206
218
  // ============ 2. preprocess messages ============ //
207
219
 
208
- const oaiMessages = this.processMessages(
220
+ const oaiMessages = await this.processMessages(
209
221
  {
210
222
  messages: parsedMessages,
211
223
  model: payload.model,
@@ -350,7 +362,10 @@ class ChatService {
350
362
 
351
363
  let fetcher: typeof fetch | undefined = undefined;
352
364
 
353
- if (enableFetchOnClient) {
365
+ // Add desktop remote RPC fetch support
366
+ if (isDesktop) {
367
+ fetcher = fetchWithInvokeStream;
368
+ } else if (enableFetchOnClient) {
354
369
  /**
355
370
  * Notes:
356
371
  * 1. Browser agent runtime will skip auth check if a key and endpoint provided by
@@ -475,7 +490,7 @@ class ChatService {
475
490
  onLoadingChange?.(true);
476
491
 
477
492
  try {
478
- const oaiMessages = this.processMessages({
493
+ const oaiMessages = await this.processMessages({
479
494
  messages: params.messages as any,
480
495
  model: params.model!,
481
496
  provider: params.provider!,
@@ -507,7 +522,7 @@ class ChatService {
507
522
  }
508
523
  };
509
524
 
510
- private processMessages = (
525
+ private processMessages = async (
511
526
  {
512
527
  messages = [],
513
528
  tools,
@@ -520,29 +535,28 @@ class ChatService {
520
535
  tools?: string[];
521
536
  },
522
537
  options?: FetchOptions,
523
- ): OpenAIChatMessage[] => {
538
+ ): Promise<OpenAIChatMessage[]> => {
524
539
  // handle content type for vision model
525
540
  // for the models with visual ability, add image url to content
526
541
  // refs: https://platform.openai.com/docs/guides/vision/quick-start
527
- const getUserContent = (m: ChatMessage) => {
542
+ const getUserContent = async (m: ChatMessage) => {
528
543
  // only if message doesn't have images and files, then return the plain content
529
544
  if ((!m.imageList || m.imageList.length === 0) && (!m.fileList || m.fileList.length === 0))
530
545
  return m.content;
531
546
 
532
547
  const imageList = m.imageList || [];
548
+ const imageContentParts = await this.processImageList({ imageList, model, provider });
533
549
 
534
550
  const filesContext = isServerMode
535
551
  ? filesPrompts({ addUrl: !isDesktop, fileList: m.fileList, imageList })
536
552
  : '';
537
553
  return [
538
554
  { 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
- ),
555
+ ...imageContentParts,
542
556
  ] as UserMessageContentPart[];
543
557
  };
544
558
 
545
- const getAssistantContent = (m: ChatMessage) => {
559
+ const getAssistantContent = async (m: ChatMessage) => {
546
560
  // signature is a signal of anthropic thinking mode
547
561
  const shouldIncludeThinking = m.reasoning && !!m.reasoning?.signature;
548
562
 
@@ -559,65 +573,70 @@ class ChatService {
559
573
  // only if message doesn't have images and files, then return the plain content
560
574
 
561
575
  if (m.imageList && m.imageList.length > 0) {
576
+ const imageContentParts = await this.processImageList({
577
+ imageList: m.imageList,
578
+ model,
579
+ provider,
580
+ });
562
581
  return [
563
582
  !!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
- ),
583
+ ...imageContentParts,
567
584
  ].filter(Boolean) as UserMessageContentPart[];
568
585
  }
569
586
 
570
587
  return m.content;
571
588
  };
572
589
 
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 };
590
+ let postMessages = await Promise.all(
591
+ messages.map(async (m): Promise<OpenAIChatMessage> => {
592
+ const supportTools = isCanUseFC(model, provider);
593
+ switch (m.role) {
594
+ case 'user': {
595
+ return { content: await getUserContent(m), role: m.role };
585
596
  }
586
597
 
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' };
598
+ case 'assistant': {
599
+ const content = await getAssistantContent(m);
600
+
601
+ if (!supportTools) {
602
+ return { content, role: m.role };
603
+ }
604
+
605
+ return {
606
+ content,
607
+ role: m.role,
608
+ tool_calls: m.tools?.map(
609
+ (tool): MessageToolCall => ({
610
+ function: {
611
+ arguments: tool.arguments,
612
+ name: genToolCallingName(tool.identifier, tool.apiName, tool.type),
613
+ },
614
+ id: tool.id,
615
+ type: 'function',
616
+ }),
617
+ ),
618
+ };
606
619
  }
607
620
 
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
- }
621
+ case 'tool': {
622
+ if (!supportTools) {
623
+ return { content: m.content, role: 'user' };
624
+ }
625
+
626
+ return {
627
+ content: m.content,
628
+ name: genToolCallingName(m.plugin!.identifier, m.plugin!.apiName, m.plugin?.type),
629
+ role: m.role,
630
+ tool_call_id: m.tool_call_id,
631
+ };
632
+ }
615
633
 
616
- default: {
617
- return { content: m.content, role: m.role as any };
634
+ default: {
635
+ return { content: m.content, role: m.role as any };
636
+ }
618
637
  }
619
- }
620
- });
638
+ }),
639
+ );
621
640
 
622
641
  postMessages = produce(postMessages, (draft) => {
623
642
  // if it's a welcome question, inject InboxGuide SystemRole
@@ -657,6 +676,37 @@ class ChatService {
657
676
  return this.reorderToolMessages(postMessages);
658
677
  };
659
678
 
679
+ /**
680
+ * Process imageList: convert local URLs to base64 and format as UserMessageContentPart
681
+ */
682
+ private processImageList = async ({
683
+ model,
684
+ provider,
685
+ imageList,
686
+ }: {
687
+ imageList: ChatImageItem[];
688
+ model: string;
689
+ provider: string;
690
+ }) => {
691
+ if (!isCanUseVision(model, provider)) {
692
+ return [];
693
+ }
694
+
695
+ return Promise.all(
696
+ imageList.map(async (image) => {
697
+ const { type } = parseDataUri(image.url);
698
+
699
+ let processedUrl = image.url;
700
+ if (type === 'url' && isLocalUrl(image.url)) {
701
+ const { base64, mimeType } = await imageUrlToBase64(image.url);
702
+ processedUrl = `data:${mimeType};base64,${base64}`;
703
+ }
704
+
705
+ return { image_url: { detail: 'auto', url: processedUrl }, type: 'image_url' } as const;
706
+ }),
707
+ );
708
+ };
709
+
660
710
  private mapTrace = (trace?: TracePayload, tag?: TraceTagMap): TracePayload => {
661
711
  const tags = sessionMetaSelectors.currentAgentMeta(getSessionStoreState()).tags || [];
662
712
 
@@ -681,9 +731,6 @@ class ChatService {
681
731
  provider: string;
682
732
  signal?: AbortSignal;
683
733
  }) => {
684
- const agentRuntime = await initializeWithClientStore(params.provider, params.payload);
685
- const data = params.payload as ChatStreamPayload;
686
-
687
734
  /**
688
735
  * if enable login and not signed in, return unauthorized error
689
736
  */
@@ -692,6 +739,9 @@ class ChatService {
692
739
  throw AgentRuntimeError.createError(ChatErrorType.InvalidAccessCode);
693
740
  }
694
741
 
742
+ const agentRuntime = await initializeWithClientStore(params.provider, params.payload);
743
+ const data = params.payload as ChatStreamPayload;
744
+
695
745
  return agentRuntime.chat(data, { signal: params.signal });
696
746
  };
697
747
 
@@ -28,13 +28,6 @@ class RemoteServerService {
28
28
  requestAuthorization = async (config: DataSyncConfig) => {
29
29
  return dispatch('requestAuthorization', config);
30
30
  };
31
-
32
- /**
33
- * 刷新访问令牌
34
- */
35
- refreshAccessToken = async () => {
36
- return dispatch('refreshAccessToken');
37
- };
38
31
  }
39
32
 
40
33
  export const remoteServerService = new RemoteServerService();
@@ -1,4 +1,4 @@
1
- import { ProxyTRPCRequestParams, dispatch } from '@lobechat/electron-client-ipc';
1
+ import { ProxyTRPCRequestParams, dispatch, streamInvoke } from '@lobechat/electron-client-ipc';
2
2
  import debug from 'debug';
3
3
 
4
4
  import { isDesktop } from '@/const/version';
@@ -6,7 +6,7 @@ import { getElectronStoreState } from '@/store/electron';
6
6
  import { electronSyncSelectors } from '@/store/electron/selectors';
7
7
  import { getRequestBody, headersToRecord } from '@/utils/fetch';
8
8
 
9
- const log = debug('lobe-lambda:desktopRemoteRPCFetch');
9
+ const log = debug('utils:desktopRemoteRPCFetch');
10
10
 
11
11
  // eslint-disable-next-line no-undef
12
12
  export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) => {
@@ -15,8 +15,8 @@ export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) =
15
15
 
16
16
  if (isSyncActive) {
17
17
  log('Using IPC proxy for tRPC request');
18
+ const url = input as string;
18
19
  try {
19
- const url = input as string;
20
20
  const parsedUrl = new URL(url, window.location.origin);
21
21
  const urlPath = parsedUrl.pathname + parsedUrl.search;
22
22
  const method = init?.method?.toUpperCase() || 'GET';
@@ -32,7 +32,7 @@ export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) =
32
32
 
33
33
  const ipcResult = await dispatch('proxyTRPCRequest', params);
34
34
 
35
- log('Received IPC proxy response:', { status: ipcResult.status });
35
+ log(`Received ${url} IPC proxy response:`, { status: ipcResult.status });
36
36
  const response = new Response(ipcResult.body, {
37
37
  headers: ipcResult.headers,
38
38
  status: ipcResult.status,
@@ -41,7 +41,7 @@ export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) =
41
41
 
42
42
  if (!response.ok) {
43
43
  console.warn(
44
- '[lambda] IPC proxy response indicates an error:',
44
+ `[lambda] ${url} IPC proxy response indicates an error:`,
45
45
  response.status,
46
46
  response.statusText,
47
47
  );
@@ -49,7 +49,7 @@ export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) =
49
49
 
50
50
  return response;
51
51
  } catch (error) {
52
- console.error('[lambda] Error during IPC proxy call:', error);
52
+ console.error(`[lambda] Error during ${url} IPC proxy call:`, error);
53
53
  return new Response(
54
54
  `IPC Proxy Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
55
55
  {
@@ -62,11 +62,26 @@ export const desktopRemoteRPCFetch = async (input: string, init?: RequestInit) =
62
62
  };
63
63
 
64
64
  // eslint-disable-next-line no-undef
65
- export const fetchWithDesktopRemoteRPC = async (input: string, init?: RequestInit) => {
65
+ export const fetchWithDesktopRemoteRPC = (async (input: RequestInfo | URL, init?: RequestInit) => {
66
66
  if (isDesktop) {
67
67
  const res = await desktopRemoteRPCFetch(input as string, init);
68
68
  if (res) return res;
69
69
  }
70
70
 
71
+ return fetch(input, init);
72
+ }) as typeof fetch;
73
+
74
+ // eslint-disable-next-line no-undef
75
+ export const fetchWithInvokeStream = async (input: RequestInfo | URL, init?: RequestInit) => {
76
+ if (isDesktop) {
77
+ const isSyncActive = electronSyncSelectors.isSyncActive(getElectronStoreState());
78
+ log('isSyncActive:', isSyncActive);
79
+ if (isSyncActive) {
80
+ log('Using IPC stream proxy for request to:', input);
81
+
82
+ return streamInvoke(input, init);
83
+ }
84
+ }
85
+
71
86
  return fetch(input, init);
72
87
  };
@@ -49,3 +49,25 @@ export const extractBearerToken = (authHeader?: string | null): string | null =>
49
49
  // Return the token only if it's not an empty string after trimming
50
50
  return token || null;
51
51
  };
52
+
53
+ /**
54
+ * 从 Oidc-Auth header 中提取 JWT token
55
+ * @param authHeader - Oidc-Auth header 值 (例如 "Oidc-Auth xxx")
56
+ * @returns JWT token 或 null(如果授权头无效或不存在)
57
+ */
58
+ export const extractOidcAuthToken = (authHeader?: string | null): string | null => {
59
+ if (!authHeader) return null;
60
+
61
+ const trimmedHeader = authHeader.trim(); // Trim leading/trailing spaces
62
+
63
+ // Check if it starts with 'Oidc-Auth ' (case-insensitive check)
64
+ if (!trimmedHeader.toLowerCase().startsWith('oidc-auth ')) {
65
+ return null;
66
+ }
67
+
68
+ // Extract the token part after "Oidc-Auth " and trim potential spaces around the token itself
69
+ const token = trimmedHeader.slice(10).trim(); // 'Oidc-Auth ' length is 10
70
+
71
+ // Return the token only if it's not an empty string after trimming
72
+ return token || null;
73
+ };
@@ -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
+ }
@@ -1,36 +0,0 @@
1
- 'use client';
2
-
3
- import { Button, Icon } from '@lobehub/ui';
4
- import { Card, Result } from 'antd';
5
- import { XCircle } from 'lucide-react';
6
- import Link from 'next/link';
7
- import React, { memo } from 'react';
8
- import { useTranslation } from 'react-i18next';
9
- import { Center } from 'react-layout-kit';
10
-
11
- const FailedPage = memo(() => {
12
- const { t } = useTranslation('oauth');
13
-
14
- return (
15
- <Center height="100vh">
16
- <Card style={{ maxWidth: 500, width: '100%' }}>
17
- <Result
18
- extra={
19
- <Link href="/">
20
- <Button type="primary">{t('failed.backToHome')}</Button>
21
- </Link>
22
- }
23
- icon={<Icon icon={XCircle} />}
24
- status="error"
25
- style={{ padding: 0 }}
26
- subTitle={t('failed.subTitle')}
27
- title={t('failed.title')}
28
- />
29
- </Card>
30
- </Center>
31
- );
32
- });
33
-
34
- FailedPage.displayName = 'FailedPage';
35
-
36
- export default FailedPage;
@@ -1,98 +0,0 @@
1
- 'use client';
2
-
3
- import { Text } from '@lobehub/ui';
4
- import { Spin } from 'antd';
5
- import { createStyles } from 'antd-style';
6
- import { useSearchParams } from 'next/navigation';
7
- import React, { useEffect, useState } from 'react';
8
- import { useTranslation } from 'react-i18next';
9
- import { Center, Flexbox } from 'react-layout-kit';
10
-
11
- const useStyles = createStyles(({ css, token }) => ({
12
- container: css`
13
- width: 100%;
14
- min-height: 100vh;
15
- padding-block: 40px;
16
- padding-inline: 24px;
17
-
18
- color: ${token.colorTextBase};
19
-
20
- background-color: ${token.colorBgLayout};
21
- `,
22
- content: css`
23
- max-width: 600px;
24
- text-align: center;
25
- `,
26
- message: css`
27
- margin-block-end: ${token.marginXL}px;
28
- color: ${token.colorTextSecondary};
29
- `,
30
- title: css`
31
- margin-block-end: ${token.marginLG}px;
32
- font-size: 24px;
33
- `,
34
- }));
35
-
36
- interface Status {
37
- desc: string;
38
- status: 'processing' | 'success';
39
- title: string;
40
- }
41
- const AuthHandoffPage = () => {
42
- const { styles } = useStyles();
43
- const { t } = useTranslation('oauth'); // Assuming 'oauth' namespace exists
44
- const searchParams = useSearchParams();
45
-
46
- const [status, setStatus] = useState<Status>({
47
- desc: t('handoff.desc.processing'),
48
- status: 'processing',
49
- title: t('handoff.title.processing'),
50
- });
51
-
52
- const [isError, setIsError] = useState<boolean>(false);
53
-
54
- useEffect(() => {
55
- const targetUrl = searchParams.get('target');
56
-
57
- if (targetUrl) {
58
- try {
59
- const decodedTargetUrl = decodeURIComponent(targetUrl);
60
- console.log(`Attempting redirect to: ${decodedTargetUrl}`);
61
-
62
- window.location.href = decodedTargetUrl;
63
-
64
- const url = new URL(decodedTargetUrl);
65
- if (!url.pathname.startsWith('/oidc/auth')) {
66
- setStatus({
67
- desc: t('handoff.desc.success'),
68
- status: 'success',
69
- title: t('handoff.title.success'),
70
- });
71
- }
72
- } catch (error) {
73
- console.error('Error decoding or redirecting:', error);
74
- // setMessage(
75
- // t('handoff.error', '无法自动打开桌面应用。请检查链接是否有效或尝试手动打开应用。'),
76
- // );
77
- setIsError(true);
78
- }
79
- } else {
80
- console.error('Missing target URL for handoff.');
81
- setIsError(true);
82
- }
83
- }, [searchParams]);
84
-
85
- return (
86
- <Center className={styles.container} gap={12}>
87
- {!isError && <Spin size="large" />}
88
- <Flexbox align="center" className={styles.content} gap={16}>
89
- <Text as={'h3'} className={styles.title}>
90
- {status.title}
91
- </Text>
92
- <Text className={styles.message}>{status.desc}</Text>
93
- </Flexbox>
94
- </Center>
95
- );
96
- };
97
-
98
- export default AuthHandoffPage;
@@ -1,13 +0,0 @@
1
- 'use client';
2
-
3
- import React, { Suspense } from 'react';
4
-
5
- import Client from './Client';
6
-
7
- const AuthHandoffPage = () => (
8
- <Suspense>
9
- <Client />
10
- </Suspense>
11
- );
12
-
13
- export default AuthHandoffPage;