@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.
- 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/.github/workflows/desktop-pr-build.yml +3 -3
- package/.github/workflows/release-desktop-beta.yml +3 -3
- package/CHANGELOG.md +50 -0
- package/apps/desktop/package.json +6 -3
- package/apps/desktop/src/main/controllers/AuthCtr.ts +310 -111
- package/apps/desktop/src/main/controllers/NetworkProxyCtr.ts +1 -1
- package/apps/desktop/src/main/controllers/RemoteServerConfigCtr.ts +50 -3
- package/apps/desktop/src/main/controllers/RemoteServerSyncCtr.ts +188 -23
- package/apps/desktop/src/main/controllers/__tests__/NetworkProxyCtr.test.ts +37 -18
- package/apps/desktop/src/main/types/store.ts +1 -0
- package/apps/desktop/src/preload/electronApi.ts +2 -1
- package/apps/desktop/src/preload/streamer.ts +58 -0
- package/changelog/v1.json +18 -0
- package/docs/development/database-schema.dbml +9 -0
- package/locales/ar/electron.json +3 -0
- package/locales/ar/oauth.json +8 -4
- package/locales/bg-BG/electron.json +3 -0
- package/locales/bg-BG/oauth.json +8 -4
- package/locales/de-DE/electron.json +3 -0
- package/locales/de-DE/oauth.json +9 -5
- package/locales/en-US/electron.json +3 -0
- package/locales/en-US/oauth.json +8 -4
- package/locales/es-ES/electron.json +3 -0
- package/locales/es-ES/oauth.json +9 -5
- package/locales/fa-IR/electron.json +3 -0
- package/locales/fa-IR/oauth.json +8 -4
- package/locales/fr-FR/electron.json +3 -0
- package/locales/fr-FR/oauth.json +8 -4
- package/locales/it-IT/electron.json +3 -0
- package/locales/it-IT/oauth.json +9 -5
- package/locales/ja-JP/electron.json +3 -0
- package/locales/ja-JP/oauth.json +8 -4
- package/locales/ko-KR/electron.json +3 -0
- package/locales/ko-KR/oauth.json +8 -4
- package/locales/nl-NL/electron.json +3 -0
- package/locales/nl-NL/oauth.json +9 -5
- package/locales/pl-PL/electron.json +3 -0
- package/locales/pl-PL/oauth.json +8 -4
- package/locales/pt-BR/electron.json +3 -0
- package/locales/pt-BR/oauth.json +8 -4
- package/locales/ru-RU/electron.json +3 -0
- package/locales/ru-RU/oauth.json +8 -4
- package/locales/tr-TR/electron.json +3 -0
- package/locales/tr-TR/oauth.json +8 -4
- package/locales/vi-VN/electron.json +3 -0
- package/locales/vi-VN/oauth.json +9 -5
- package/locales/zh-CN/electron.json +3 -0
- package/locales/zh-CN/oauth.json +8 -4
- package/locales/zh-TW/electron.json +3 -0
- package/locales/zh-TW/oauth.json +8 -4
- package/package.json +3 -3
- package/packages/electron-client-ipc/src/dispatch.ts +14 -2
- package/packages/electron-client-ipc/src/index.ts +1 -0
- package/packages/electron-client-ipc/src/streamInvoke.ts +62 -0
- package/packages/electron-client-ipc/src/types/proxyTRPCRequest.ts +5 -0
- package/packages/electron-client-ipc/src/utils/headers.ts +27 -0
- package/packages/electron-client-ipc/src/utils/request.ts +28 -0
- package/src/app/(backend)/oidc/callback/desktop/route.ts +58 -0
- package/src/app/(backend)/oidc/handoff/route.ts +46 -0
- package/src/app/[variants]/oauth/callback/error/page.tsx +55 -0
- package/src/app/[variants]/oauth/callback/layout.tsx +12 -0
- package/src/app/[variants]/oauth/callback/loading.tsx +3 -0
- package/src/app/[variants]/oauth/{consent/[uid] → callback}/success/page.tsx +10 -1
- package/src/app/[variants]/oauth/consent/[uid]/Consent.tsx +7 -1
- package/src/database/client/migrations.json +8 -0
- package/src/database/migrations/0028_oauth_handoffs.sql +8 -0
- package/src/database/migrations/meta/0028_snapshot.json +6055 -0
- package/src/database/migrations/meta/_journal.json +7 -0
- package/src/database/models/oauthHandoff.ts +94 -0
- package/src/database/repositories/tableViewer/index.test.ts +1 -1
- package/src/database/schemas/oidc.ts +46 -0
- package/src/features/ElectronTitlebar/Connection/Waiting.tsx +59 -115
- package/src/features/ElectronTitlebar/Connection/WaitingAnim.tsx +114 -0
- package/src/libs/oidc-provider/config.ts +16 -17
- package/src/libs/oidc-provider/jwt.ts +135 -0
- package/src/libs/oidc-provider/provider.ts +22 -38
- package/src/libs/trpc/client/async.ts +1 -2
- package/src/libs/trpc/client/edge.ts +1 -2
- package/src/libs/trpc/client/lambda.ts +1 -1
- package/src/libs/trpc/client/tools.ts +1 -2
- package/src/libs/trpc/lambda/context.ts +9 -16
- package/src/locales/default/electron.ts +3 -0
- package/src/locales/default/oauth.ts +8 -4
- package/src/middleware.ts +10 -4
- package/src/server/services/oidc/index.ts +0 -71
- package/src/services/__tests__/chat.test.ts +998 -62
- package/src/services/chat.ts +109 -59
- package/src/services/electron/remoteServer.ts +0 -7
- package/src/{libs/trpc/client/helpers → utils/electron}/desktopRemoteRPCFetch.ts +22 -7
- package/src/utils/server/auth.ts +22 -0
- package/src/utils/url.test.ts +42 -1
- package/src/utils/url.ts +28 -0
- package/src/app/[variants]/oauth/consent/[uid]/failed/page.tsx +0 -36
- package/src/app/[variants]/oauth/handoff/Client.tsx +0 -98
- package/src/app/[variants]/oauth/handoff/page.tsx +0 -13
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,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
|
-
|
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
|
-
...
|
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
|
-
...
|
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 =
|
574
|
-
|
575
|
-
|
576
|
-
|
577
|
-
|
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
|
-
|
588
|
-
content
|
589
|
-
|
590
|
-
|
591
|
-
|
592
|
-
|
593
|
-
|
594
|
-
|
595
|
-
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
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
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
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
|
-
|
617
|
-
|
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('
|
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(
|
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
|
-
|
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(
|
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:
|
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
|
};
|
package/src/utils/server/auth.ts
CHANGED
@@ -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
|
+
};
|
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
|
+
}
|
@@ -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;
|